Feat : Allow bulk pdf compress (#67)

* Feat :  Allow bulk pdf compress

* feat(pdf-tools): add sanitize-pdf tool for comprehensive document cleaning

Implement a new PDF sanitization tool that allows users to remove various potentially sensitive elements from PDFs including metadata, annotations, JavaScript, embedded files, and more. The tool provides configurable options through checkboxes to selectively remove different types of content while preserving the core document structure.

Extract reusable utility functions from existing tools (remove-metadata, remove-annotations, flatten) to support the new sanitization feature. The tool handles edge cases gracefully and provides feedback when no changes are made.

* fix(sanitize-pdf): remove javascript actions, external links and font files

- Remove JavaScript actions from annotations and form fields
- Remove external links (URI, Launch, GoTo) from annotations
- Remove embedded font files while preserving font descriptors
- Add detailed error logging for each operation

* fix(sanitize-pdf): improve link removal and add named destinations cleanup

- Refactor link annotation removal logic to handle more action types (URI, Launch, GoTo, GoToR)
- Add cleanup of named destinations in catalog and names dictionary
- Improve error handling and logging throughout the sanitization process

* chore: update config files, icons and readme

- Update FUNDING.yml formatting
- Remove debug log in sanitize-pdf.ts
- Adjust test expectation for tool count
- Standardize dependabot.yml quotes
- Update tool icons in config
- Expand and reorganize README features list
- Fix markdown formatting in README

* Release v1.1.0

---------

Co-authored-by: abdullahalam123 <adullah.alam.tapadar2000@gmail.com>
Co-authored-by: Alam <50314772+alam00000@users.noreply.github.com>
This commit is contained in:
Divy Sangwan
2025-10-22 23:06:59 +05:30
committed by GitHub
co-authored by abdullahalam123 Alam
parent c9c3b33704
commit a8c3f9ffdd
6 changed files with 255 additions and 61 deletions
+1 -1
View File
@@ -6,7 +6,6 @@ export const singlePdfLoadTools = [
'pdf-to-jpg',
'pdf-to-png',
'pdf-to-webp',
'compress',
'pdf-to-greyscale',
'edit-metadata',
'remove-metadata',
@@ -63,4 +62,5 @@ export const multiFileTools = [
'alternate-merge',
'linearize',
'reverse-pages',
'compress'
];
+7 -6
View File
@@ -440,11 +440,7 @@ async function handleSinglePdfUpload(toolId, file) {
}
async function handleMultiFileUpload(toolId) {
if (
toolId === 'merge' ||
toolId === 'alternate-merge' ||
toolId === 'reverse-pages'
) {
if (toolId === 'merge' || toolId === 'alternate-merge' || toolId === 'reverse-pages' || toolId === 'compress') {
const pdfFilesUnloaded: File[] = [];
state.files.forEach((file) => {
@@ -496,7 +492,12 @@ async function handleMultiFileUpload(toolId) {
processBtn.onclick = func;
}
}
if (toolId === 'compress') {
const optionsDiv = document.getElementById('compress-options');
if (optionsDiv) {
optionsDiv.classList.remove('hidden');
}
}
if (toolId === 'merge') {
toolLogic.merge.setup();
} else if (toolId === 'alternate-merge') {
+73 -52
View File
@@ -6,7 +6,7 @@ import {
} from '../utils/helpers.js';
import { state } from '../state.js';
import * as pdfjsLib from 'pdfjs-dist';
import JSZip from 'jszip';
import { PDFDocument, PDFName, PDFDict, PDFStream, PDFNumber } from 'pdf-lib';
function dataUrlToBytes(dataUrl: any) {
@@ -270,68 +270,89 @@ export async function compress() {
const smartSettings = { ...settings[level].smart, removeMetadata: true };
const legacySettings = settings[level].legacy;
const pdfFiles = state.files.filter((f) => f.type === 'application/pdf');
if (!pdfFiles.length) {
showAlert('Error', 'No PDF files found to compress.');
return;
}
showLoader(`Compressing ${pdfFiles.length} PDF(s)...`);
const zip = new JSZip();
let totalOriginal = 0;
let totalCompressed = 0;
try {
const originalFile = state.files[0];
const arrayBuffer = await readFileAsArrayBuffer(originalFile);
for (let i = 0; i < pdfFiles.length; i++) {
const originalFile = pdfFiles[i];
const arrayBuffer = await readFileAsArrayBuffer(originalFile);
let resultBytes;
let usedMethod;
let resultBytes;
let usedMethod;
if (algorithm === 'vector') {
showLoader('Running Vector (Smart) compression...');
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
usedMethod = 'Vector';
} else if (algorithm === 'photon') {
showLoader('Running Photon (Rasterize) compression...');
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
usedMethod = 'Photon';
} else {
showLoader('Running Automatic (Vector first)...');
const vectorResultBytes = await performSmartCompression(
arrayBuffer,
smartSettings
);
if (vectorResultBytes.length < originalFile.size) {
resultBytes = vectorResultBytes;
usedMethod = 'Vector (Automatic)';
if (algorithm === 'vector') {
showLoader('Running Vector (Smart) compression...');
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
usedMethod = 'Vector';
} else if (algorithm === 'photon') {
showLoader('Running Photon (Rasterize) compression...');
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
usedMethod = 'Photon';
} else {
showAlert('Vector failed to reduce size. Trying Photon...', 'info');
showLoader('Running Automatic (Photon fallback)...');
resultBytes = await performLegacyCompression(
showLoader('Running Automatic (Vector first)...');
const vectorResultBytes = await performSmartCompression(
arrayBuffer,
legacySettings
smartSettings
);
usedMethod = 'Photon (Automatic)';
if (vectorResultBytes.length < originalFile.size) {
resultBytes = vectorResultBytes;
usedMethod = 'Vector (Automatic)';
} else {
showAlert('Vector failed to reduce size. Trying Photon...', 'info');
showLoader('Running Automatic (Photon fallback)...');
resultBytes = await performLegacyCompression(
arrayBuffer,
legacySettings
);
usedMethod = 'Photon (Automatic)';
}
}
const originalSize = formatBytes(originalFile.size);
const compressedSize = formatBytes(resultBytes.length);
const savings = originalFile.size - resultBytes.length;
const savingsPercent =
savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
totalOriginal += originalFile.size;
totalCompressed += resultBytes.length;
if (savings > 0) {
showAlert(
'Compression Complete',
`Method: **${usedMethod}**. ` +
`File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`
);
} else {
showAlert(
'Compression Finished',
`Method: **${usedMethod}**. ` +
`Could not reduce file size. Original: ${originalSize}, New: ${compressedSize}.`,
// @ts-expect-error TS(2554) FIXME: Expected 2 arguments, but got 3.
'warning'
);
}
zip.file(`compressed-${originalFile.name}`, resultBytes);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'compressed_pdfs.zip');
const originalSize = formatBytes(originalFile.size);
const compressedSize = formatBytes(resultBytes.length);
const savings = originalFile.size - resultBytes.length;
const savingsPercent =
savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
const totalSavings = totalOriginal - totalCompressed;
const totalPercent = ((totalSavings / totalOriginal) * 100).toFixed(1);
if (savings > 0) {
showAlert(
'Compression Complete',
`Method: **${usedMethod}**. ` +
`File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`
);
} else {
showAlert(
'Compression Finished',
`Method: **${usedMethod}**. ` +
`Could not reduce file size. Original: ${originalSize}, New: ${compressedSize}.`,
// @ts-expect-error TS(2554) FIXME: Expected 2 arguments, but got 3.
'warning'
);
}
downloadFile(
new Blob([resultBytes], { type: 'application/pdf' }),
'compressed-final.pdf'
showAlert(
'All Files Compressed',
`Total saved: ${totalPercent}% (${formatBytes(totalOriginal)}${formatBytes(totalCompressed)})`
);
} catch (e) {
showAlert(
+1 -1
View File
@@ -532,7 +532,7 @@ export const toolTemplates = {
compress: () => `
<h2 class="text-2xl font-bold text-white mb-4">Compress PDF</h2>
<p class="mb-6 text-gray-400">Reduce file size by choosing the compression method that best suits your document.</p>
${createFileInputHTML()}
${createFileInputHTML({ multiple: true, accept: 'application/pdf', showControls: true })}
<div id="file-display-area" class="mt-4 space-y-2"></div>
<div id="compress-options" class="hidden mt-6 space-y-6">
<div>
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import JSZip from 'jszip';
import * as helpers from '../js/utils/helpers';
import * as ui from '../js/ui';
import { state } from '../js/state';
import { compress } from '../js/logic/compress';
import { PDFDocument } from 'pdf-lib';
// --- Mock UI functions ---
vi.mock('../js/ui', () => ({
showLoader: vi.fn(),
hideLoader: vi.fn(),
showAlert: vi.fn(),
}));
// --- Mock helpers ---
vi.mock('../js/utils/helpers', () => ({
readFileAsArrayBuffer: vi.fn(),
downloadFile: vi.fn(),
formatBytes: (size: number) => `${size}B`,
}));
// --- Mock PDF-lib ---
vi.mock('pdf-lib', async () => {
const actual = await vi.importActual<typeof import('pdf-lib')>('pdf-lib');
return {
...actual,
PDFDocument: {
load: vi.fn().mockResolvedValue({
getPages: vi.fn().mockReturnValue([{ node: { Resources: () => null } }]),
save: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
setTitle: vi.fn(),
setAuthor: vi.fn(),
setSubject: vi.fn(),
setKeywords: vi.fn(),
setCreator: vi.fn(),
setProducer: vi.fn(),
}),
create: vi.fn().mockResolvedValue({
addPage: vi.fn().mockReturnValue({ drawImage: vi.fn() }),
embedJpg: vi.fn().mockResolvedValue({}),
save: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
}),
},
};
});
vi.mock('pdfjs-dist', () => {
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn(() => ({
promise: Promise.resolve({
numPages: 1,
getPage: vi.fn().mockResolvedValue({
getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }),
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
}),
}),
})),
};
});
// --- Mock canvas & Image for Node environment ---
class MockCanvas {
width = 0;
height = 0;
getContext = vi.fn().mockReturnValue({
drawImage: vi.fn(),
imageSmoothingEnabled: true,
imageSmoothingQuality: 'medium',
filter: '',
});
toDataURL = vi.fn().mockReturnValue('data:image/jpeg;base64,abc');
}
vi.stubGlobal('HTMLCanvasElement', MockCanvas as any);
vi.stubGlobal('Image', class {
onload: Function = () => {};
onerror: Function = () => {};
set src(_url: string) { setTimeout(() => this.onload(), 0); }
});
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue('blob://mock'),
revokeObjectURL: vi.fn(),
});
beforeEach(() => {
state.files = [];
vi.clearAllMocks();
// Fake DOM inputs
document.body.innerHTML = `
<input id="compression-level" value="balanced" />
<input id="compression-algorithm" value="vector" />
`;
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('compress()', () => {
it('should show alert if no PDFs are loaded', async () => {
state.files = [];
await compress();
expect(ui.showAlert).toHaveBeenCalledWith('Error', 'No PDF files found to compress.');
});
it('should compress multiple PDFs successfully (vector)', async () => {
const mockFile = (name: string, size = 1000) => ({
name,
type: 'application/pdf',
size,
});
state.files = [mockFile('a.pdf'), mockFile('b.pdf')];
vi.spyOn(helpers, 'readFileAsArrayBuffer').mockResolvedValue(new ArrayBuffer(8));
vi.spyOn(helpers, 'downloadFile').mockImplementation(() => {});
await compress();
expect(ui.showLoader).toHaveBeenCalledWith(expect.stringContaining('Compressing 2 PDF'));
expect(helpers.downloadFile).toHaveBeenCalledWith(expect.any(Blob), 'compressed_pdfs.zip');
expect(ui.showAlert).toHaveBeenCalledWith(
'All Files Compressed',
expect.stringContaining('Total saved:')
);
});
it('should handle errors gracefully', async () => {
state.files = [{ name: 'a.pdf', type: 'application/pdf', size: 1000 }];
vi.spyOn(helpers, 'readFileAsArrayBuffer').mockRejectedValue(new Error('read failed'));
await compress();
expect(ui.showAlert).toHaveBeenCalledWith(
'Error',
expect.stringContaining('An error occurred during compression')
);
expect(ui.hideLoader).toHaveBeenCalled();
});
it('should fallback to Photon when vector compression does not reduce size', async () => {
state.files = [{ name: 'a.pdf', type: 'application/pdf', size: 1000 }];
vi.spyOn(helpers, 'readFileAsArrayBuffer').mockResolvedValue(new ArrayBuffer(8));
// Force vector compression to not reduce size
vi.spyOn(PDFDocument, 'load').mockResolvedValueOnce({
getPages: () => [{ node: { Resources: () => null } }],
save: async () => new Uint8Array(1000), // same size as input
setTitle: vi.fn(),
setAuthor: vi.fn(),
setSubject: vi.fn(),
setKeywords: vi.fn(),
setCreator: vi.fn(),
setProducer: vi.fn(),
} as any);
await compress();
expect(ui.showAlert).toHaveBeenCalledWith(
'Compression Finished',
expect.stringContaining('Could not reduce file size'),
'warning'
);
expect(ui.showAlert).toHaveBeenCalledWith(
'All Files Compressed',
expect.stringContaining('Total saved: 0.0%')
);
});
});
+1 -1
View File
@@ -61,7 +61,7 @@ describe('Tool Configuration Arrays', () => {
});
it('should have the correct number of tools', () => {
expect(multiFileTools).toHaveLength(13);
expect(multiFileTools).toHaveLength(14);
});
it('should not contain any duplicate tools', () => {