mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-08-17 21:25:47 +02:00
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:
co-authored by
abdullahalam123
Alam
parent
c9c3b33704
commit
a8c3f9ffdd
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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%')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user