diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58aa29a..beb05ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: node-version: 24 cache: npm - run: npm ci + - run: npm audit --omit=dev --audit-level=high - run: npm run privacy-check - run: npm run lint - run: npm run typecheck diff --git a/.gitignore b/.gitignore index 5ef6a52..b72755b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ /.next/ /out/ +# generated browser runtime assets +/public/qpdf/ +/public/pdfjs/ + # production /build diff --git a/README.md b/README.md index d164df8..deb0702 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ ToolStack is an open-source collection of utilities designed around a strict loc ## Highlights ### PDF & Documents -- PDF Toolkit: merge PDFs, reorder merge inputs, extract/reorder/delete/rotate pages, split every page to ZIP, edit basic metadata and turn images into a PDF. -- PDF operations run in-browser with bundled `pdf-lib`; ToolStack does not claim secure redaction or general PDF compression. +- PDF Toolkit: compress PDFs, merge and reorder merge inputs, remove accidental uploads, select which loaded PDF to edit, extract/reorder/delete/rotate pages, split every page to ZIP, edit basic metadata and turn images into a PDF. +- Lossless PDF compression runs in-browser with bundled qpdf WebAssembly and preserves document structure, selectable text, links and forms. +- Balanced and Strong compression use bundled PDF.js to render pages locally and rebuild them as JPEG-backed PDF pages. This can produce much larger reductions for scans/image-heavy files, but intentionally flattens the document: selectable text, links, forms, bookmarks, accessibility structure and digital signatures are not preserved. +- Other PDF editing operations use bundled `pdf-lib`. ToolStack does not claim secure redaction. ### Privacy & Security Image Metadata Remover, Data Anonymizer, File Hash & Checksum, Tracking URL Cleaner, Password & Passphrase Generator, JWT Inspector, Browser Privacy Inspector, Text Privacy Cleaner, AES-GCM File Encryption, File Type & Signature Inspector, Content Credentials (C2PA) Inspector, and HMAC Generator & Verifier. diff --git a/package-lock.json b/package-lock.json index 296d56a..3a6dcc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "toolstack", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "toolstack", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@contentauth/c2pa-web": "^0.14.4", "browser-image-compression": "^2.0.2", @@ -18,8 +18,10 @@ "next": "16.3.4", "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", + "pdfjs-dist": "^6.3.289", "piexifjs": "^1.0.6", "postcss-selector-parser": "^7.1.6", + "qpdf-run": "^0.2.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-image-crop": "^11.0.10", @@ -1229,6 +1231,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz", + "integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.8", + "@napi-rs/canvas-darwin-arm64": "1.0.8", + "@napi-rs/canvas-darwin-x64": "1.0.8", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", + "@napi-rs/canvas-linux-arm64-musl": "1.0.8", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-musl": "1.0.8", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", + "@napi-rs/canvas-win32-x64-msvc": "1.0.8" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz", + "integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz", + "integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz", + "integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz", + "integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz", + "integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz", + "integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz", + "integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz", + "integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz", + "integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz", + "integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz", + "integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz", @@ -6218,6 +6485,18 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/pdfjs-dist": { + "version": "6.3.289", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", + "integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6333,6 +6612,12 @@ "node": ">=6" } }, + "node_modules/qpdf-run": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/qpdf-run/-/qpdf-run-0.2.1.tgz", + "integrity": "sha512-X4ZknJPl7av/o+VEX3XafWyzfOUm9qSoa+JqXS+hGSNVI1Wxk60bKhmLvvX/8gp7pCzbp3KW0zY8aLBjL5EkYQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 1eb7dd9..088da63 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "toolstack", - "version": "0.3.0", + "version": "0.3.1", "private": true, "scripts": { + "prepare-qpdf-assets": "node scripts/copy-qpdf-assets.mjs", + "predev": "npm run prepare-qpdf-assets", "dev": "next dev", + "prebuild": "npm run prepare-qpdf-assets", "build": "next build", "start": "next start", "lint": "eslint .", @@ -23,8 +26,10 @@ "next": "16.3.4", "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", + "pdfjs-dist": "^6.3.289", "piexifjs": "^1.0.6", "postcss-selector-parser": "^7.1.6", + "qpdf-run": "^0.2.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-image-crop": "^11.0.10", diff --git a/scripts/copy-qpdf-assets.mjs b/scripts/copy-qpdf-assets.mjs new file mode 100644 index 0000000..7788dab --- /dev/null +++ b/scripts/copy-qpdf-assets.mjs @@ -0,0 +1,43 @@ +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const qpdfPackageRoot = join(projectRoot, 'node_modules', 'qpdf-run'); +const qpdfOutputRoot = join(projectRoot, 'public', 'qpdf'); +const pdfjsPackageRoot = join(projectRoot, 'node_modules', 'pdfjs-dist'); +const pdfjsOutputRoot = join(projectRoot, 'public', 'pdfjs'); +const workerPath = join(qpdfPackageRoot, 'src', 'worker.js'); +const marker = 'TOOLSTACK_QPDF_ASSET_FALLBACK'; + +await mkdir(qpdfOutputRoot, { recursive: true }); +await mkdir(pdfjsOutputRoot, { recursive: true }); + +let workerSource = await readFile(workerPath, 'utf8'); + +// Next 16/Turbopack can turn qpdf-run's companion asset URLs into file:/// URLs +// inside the worker. A browser worker cannot importScripts() from file:// when the +// application is being served over HTTP(S), so fall back to ToolStack's same-origin +// public/qpdf copies when that happens. +if (!workerSource.includes(marker)) { + const initLine = ' await initQpdf(message.qpdfJsUrl, message.wasmUrl);'; + if (!workerSource.includes(initLine)) { + throw new Error('qpdf-run worker layout changed; update scripts/copy-qpdf-assets.mjs before continuing.'); + } + + workerSource = workerSource.replace( + initLine, + ` // ${marker}\n await initQpdf(resolveToolStackAsset(message.qpdfJsUrl, 'qpdf.js'), resolveToolStackAsset(message.wasmUrl, 'qpdf.wasm'));`, + ); + + workerSource += `\nfunction resolveToolStackAsset(url, filename) {\n try {\n var resolved = new URL(String(url || ''), self.location.href);\n if (resolved.protocol !== 'file:') return resolved.href;\n } catch (_) {\n // Fall through to the same-origin public asset.\n }\n return new URL('/qpdf/' + filename, self.location.origin).href;\n}\n`; + + await writeFile(workerPath, workerSource, 'utf8'); +} + +await writeFile(join(qpdfOutputRoot, 'worker.js'), workerSource, 'utf8'); +await copyFile(join(qpdfPackageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.js'), join(qpdfOutputRoot, 'qpdf.js')); +await copyFile(join(qpdfPackageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.wasm'), join(qpdfOutputRoot, 'qpdf.wasm')); +await copyFile(join(pdfjsPackageRoot, 'build', 'pdf.worker.min.mjs'), join(pdfjsOutputRoot, 'pdf.worker.min.mjs')); + +console.log('Prepared qpdf and PDF.js browser assets for local PDF compression.'); diff --git a/src/app/pdf-toolkit/ClientMountBoundary.tsx b/src/app/pdf-toolkit/ClientMountBoundary.tsx new file mode 100644 index 0000000..e0f5b48 --- /dev/null +++ b/src/app/pdf-toolkit/ClientMountBoundary.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useSyncExternalStore, type ReactNode } from 'react'; + +const subscribe = () => () => undefined; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +export default function ClientMountBoundary({ children }: { children: ReactNode }) { + const mounted = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot); + + if (!mounted) { + return
+
+
+

Preparing PDF Toolkit…

+

Loading the browser-only PDF workspace. Your files stay on this device.

+
+
+
; + } + + return children; +} diff --git a/src/app/pdf-toolkit/layout.tsx b/src/app/pdf-toolkit/layout.tsx new file mode 100644 index 0000000..82c22ab --- /dev/null +++ b/src/app/pdf-toolkit/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import ClientMountBoundary from './ClientMountBoundary'; + +export const metadata: Metadata = { + title: 'PDF Toolkit', + description: 'Compress, merge, split, reorder, extract, delete and rotate PDFs locally in your browser without uploading documents.', +}; + +export default function PdfToolkitLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} diff --git a/src/app/pdf-toolkit/page.tsx b/src/app/pdf-toolkit/page.tsx index 28e5e36..d521e87 100644 --- a/src/app/pdf-toolkit/page.tsx +++ b/src/app/pdf-toolkit/page.tsx @@ -1,4 +1,5 @@ 'use client'; + import { useState } from 'react'; import JSZip from 'jszip'; import { degrees, PDFDocument } from 'pdf-lib'; @@ -7,10 +8,18 @@ import { parsePageSelection } from '@/lib/page-selection'; type PdfInfo = { file: File; pages: number; error?: string }; type MetadataState = { title: string; author: string; subject: string; keywords: string }; +type CompressionMode = 'lossless' | 'balanced' | 'strong'; +type CompressionResult = { original: number; compressed: number; downloaded: boolean }; + const EMPTY_META: MetadataState = { title: '', author: '', subject: '', keywords: '' }; const MAX_PDF_FILES = 20; const MAX_TOTAL_BYTES = 250 * 1024 * 1024; +const MAX_COMPRESSION_BYTES = 100 * 1024 * 1024; +const MAX_RASTER_PAGES = 150; +const MAX_RASTER_PAGE_PIXELS = 20_000_000; +const MAX_RASTER_TOTAL_PIXELS = 220_000_000; const fileKey = (file: File) => `${file.name}:${file.size}:${file.lastModified}`; +const baseName = (name: string) => name.replace(/\.pdf$/i, ''); function downloadBytes(bytes: Uint8Array, name: string, type = 'application/pdf') { const blob = new Blob([new Uint8Array(bytes).buffer], { type }); @@ -22,18 +31,148 @@ function downloadBytes(bytes: Uint8Array, name: string, type = 'application/pdf' URL.revokeObjectURL(url); } -async function loadPdf(file: File) { return PDFDocument.load(await file.arrayBuffer()); } -const baseName = (name: string) => name.replace(/\.pdf$/i, ''); +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && value >= 1024; index++) { + value /= 1024; + unit = units[index]; + } + return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`; +} + +async function loadPdf(file: File) { + return PDFDocument.load(await file.arrayBuffer()); +} + +async function readMetadata(item: PdfInfo): Promise { + const doc = await loadPdf(item.file); + return { + title: doc.getTitle() ?? '', + author: doc.getAuthor() ?? '', + subject: doc.getSubject() ?? '', + keywords: doc.getKeywords() ?? '', + }; +} + +function losslessCompressionArgs() { + return [ + '--compress-streams=y', + '--decode-level=generalized', + '--recompress-flate', + '--compression-level=9', + '--object-streams=generate', + '--', + 'input.pdf', + 'output.pdf', + ]; +} + +function rasterSettings(mode: Exclude) { + return mode === 'balanced' + ? { dpi: 150, jpegQuality: 0.72 } + : { dpi: 96, jpegQuality: 0.52 }; +} + +function canvasToJpeg(canvas: HTMLCanvasElement, quality: number) { + return new Promise((resolve, reject) => { + canvas.toBlob(async blob => { + if (!blob) { + reject(new Error('The browser could not encode a compressed PDF page.')); + return; + } + resolve(new Uint8Array(await blob.arrayBuffer())); + }, 'image/jpeg', quality); + }); +} + +async function rasterCompressPdf( + file: File, + mode: Exclude, + onProgress: (current: number, total: number) => void, +) { + const pdfjs = await import('pdfjs-dist'); + pdfjs.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs'; + + const input = new Uint8Array(await file.arrayBuffer()); + const loadingTask = pdfjs.getDocument({ data: input }); + const source = await loadingTask.promise; + const { dpi, jpegQuality } = rasterSettings(mode); + + try { + if (source.numPages > MAX_RASTER_PAGES) { + throw new Error(`Balanced/Strong compression is limited to ${MAX_RASTER_PAGES} pages. Use Lossless for larger documents.`); + } + + const output = await PDFDocument.create(); + let totalPixels = 0; + + for (let pageNumber = 1; pageNumber <= source.numPages; pageNumber++) { + onProgress(pageNumber, source.numPages); + const page = await source.getPage(pageNumber); + const baseViewport = page.getViewport({ scale: 1 }); + let scale = dpi / 72; + const projectedPixels = baseViewport.width * scale * baseViewport.height * scale; + + if (projectedPixels > MAX_RASTER_PAGE_PIXELS) { + scale *= Math.sqrt(MAX_RASTER_PAGE_PIXELS / projectedPixels); + } + + const viewport = page.getViewport({ scale }); + const width = Math.max(1, Math.ceil(viewport.width)); + const height = Math.max(1, Math.ceil(viewport.height)); + totalPixels += width * height; + + if (totalPixels > MAX_RASTER_TOTAL_PIXELS) { + throw new Error('Balanced/Strong compression would require too much browser memory. Split this PDF or use Lossless mode.'); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { alpha: false }); + if (!context) throw new Error('Canvas rendering is unavailable in this browser.'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + + await page.render({ canvas, canvasContext: context, viewport, background: '#ffffff' }).promise; + const jpeg = await canvasToJpeg(canvas, jpegQuality); + const image = await output.embedJpg(jpeg); + const outputPage = output.addPage([baseViewport.width, baseViewport.height]); + outputPage.drawImage(image, { x: 0, y: 0, width: baseViewport.width, height: baseViewport.height }); + page.cleanup(); + canvas.width = 1; + canvas.height = 1; + } + + return await output.save({ useObjectStreams: true }); + } finally { + await loadingTask.destroy(); + } +} export default function PdfToolkitPage() { const [files, setFiles] = useState([]); + const [selectedKey, setSelectedKey] = useState(''); const [selection, setSelection] = useState(''); - const [operation, setOperation] = useState<'extract'|'delete'|'rotate'>('extract'); + const [operation, setOperation] = useState<'extract' | 'delete' | 'rotate'>('extract'); const [rotation, setRotation] = useState(90); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(''); const [metadata, setMetadata] = useState(EMPTY_META); const [images, setImages] = useState([]); + const [compressionMode, setCompressionMode] = useState('lossless'); + const [compressionResult, setCompressionResult] = useState(null); + const [compressionStatus, setCompressionStatus] = useState(''); + + const activatePdf = async (item: PdfInfo) => { + setSelectedKey(fileKey(item.file)); + setSelection(`1-${item.pages}`); + setMetadata(await readMetadata(item)); + setCompressionResult(null); + }; const addPdfs = async (list: FileList | null) => { if (!list) return; @@ -55,7 +194,6 @@ export default function PdfToolkitPage() { return; } - const hadReadablePdf = files.some(item => !item.error && item.pages > 0); setBusy(true); setMessage(''); try { @@ -69,20 +207,65 @@ export default function PdfToolkitPage() { })); setFiles(prev => [...prev, ...loaded]); - if (!hadReadablePdf) { + if (!selectedKey) { const first = loaded.find(item => !item.error && item.pages > 0); - if (first) { - setSelection(`1-${first.pages}`); - const doc = await loadPdf(first.file); - setMetadata({ title: doc.getTitle() ?? '', author: doc.getAuthor() ?? '', subject: doc.getSubject() ?? '', keywords: doc.getKeywords() ?? '' }); - } + if (first) await activatePdf(first); } } finally { setBusy(false); } }; - const moveFile = (index: number, direction: -1|1) => setFiles(prev => { + const selectWorkingPdf = async (key: string) => { + const item = files.find(candidate => fileKey(candidate.file) === key && !candidate.error && candidate.pages > 0); + if (!item) return; + setBusy(true); + setMessage(''); + try { + await activatePdf(item); + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } + }; + + const removeFile = async (key: string) => { + const next = files.filter(item => fileKey(item.file) !== key); + setFiles(prev => prev.filter(item => fileKey(item.file) !== key)); + setMessage(''); + + if (selectedKey !== key) return; + + const replacement = next.find(item => !item.error && item.pages > 0); + if (!replacement) { + setSelectedKey(''); + setSelection(''); + setMetadata(EMPTY_META); + setCompressionResult(null); + return; + } + + setBusy(true); + try { + await activatePdf(replacement); + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } + }; + + const clearFiles = () => { + setFiles([]); + setSelectedKey(''); + setSelection(''); + setMetadata(EMPTY_META); + setCompressionResult(null); + setMessage(''); + }; + + const moveFile = (index: number, direction: -1 | 1) => setFiles(prev => { const target = index + direction; if (target < 0 || target >= prev.length) return prev; const copy = [...prev]; @@ -103,22 +286,24 @@ export default function PdfToolkitPage() { copied.forEach(page => output.addPage(page)); } downloadBytes(await output.save(), 'merged.pdf'); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const applyPages = async () => { - const first = files.find(item => !item.error && item.pages > 0); - if (!first) return; + const applyPages = async (workingPdf: PdfInfo) => { setBusy(true); setMessage(''); try { - const source = await loadPdf(first.file); + const source = await loadPdf(workingPdf.file); const indexes = parsePageSelection(selection, source.getPageCount()); if (operation === 'extract') { const output = await PDFDocument.create(); const copied = await output.copyPages(source, indexes); copied.forEach(page => output.addPage(page)); - downloadBytes(await output.save(), `${baseName(first.file.name)}-selected.pdf`); + downloadBytes(await output.save(), `${baseName(workingPdf.file.name)}-selected.pdf`); } else if (operation === 'delete') { const remove = new Set(indexes); const keep = source.getPageIndices().filter(index => !remove.has(index)); @@ -126,55 +311,125 @@ export default function PdfToolkitPage() { const output = await PDFDocument.create(); const copied = await output.copyPages(source, keep); copied.forEach(page => output.addPage(page)); - downloadBytes(await output.save(), `${baseName(first.file.name)}-pages-removed.pdf`); + downloadBytes(await output.save(), `${baseName(workingPdf.file.name)}-pages-removed.pdf`); } else { for (const index of new Set(indexes)) { const page = source.getPage(index); page.setRotation(degrees((page.getRotation().angle + rotation + 360) % 360)); } - downloadBytes(await source.save(), `${baseName(first.file.name)}-rotated.pdf`); + downloadBytes(await source.save(), `${baseName(workingPdf.file.name)}-rotated.pdf`); } - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const split = async () => { - const first = files.find(item => !item.error && item.pages > 0); - if (!first) return; + const split = async (workingPdf: PdfInfo) => { setBusy(true); setMessage(''); try { - const source = await loadPdf(first.file); + const source = await loadPdf(workingPdf.file); const zip = new JSZip(); for (const index of source.getPageIndices()) { const output = await PDFDocument.create(); const [page] = await output.copyPages(source, [index]); output.addPage(page); - zip.file(`${baseName(first.file.name)}-page-${String(index + 1).padStart(3,'0')}.pdf`, await output.save()); + zip.file(`${baseName(workingPdf.file.name)}-page-${String(index + 1).padStart(3, '0')}.pdf`, await output.save()); } const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `${baseName(first.file.name)}-split.zip`; + link.download = `${baseName(workingPdf.file.name)}-split.zip`; link.click(); URL.revokeObjectURL(url); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const saveMetadata = async () => { - const first = files.find(item => !item.error && item.pages > 0); - if (!first) return; + const saveMetadata = async (workingPdf: PdfInfo) => { setBusy(true); setMessage(''); try { - const doc = await loadPdf(first.file); + const doc = await loadPdf(workingPdf.file); if (metadata.title) doc.setTitle(metadata.title); if (metadata.author) doc.setAuthor(metadata.author); if (metadata.subject) doc.setSubject(metadata.subject); doc.setKeywords(metadata.keywords.split(',').map(value => value.trim()).filter(Boolean)); doc.setModificationDate(new Date()); - downloadBytes(await doc.save(), `${baseName(first.file.name)}-metadata.pdf`); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + downloadBytes(await doc.save(), `${baseName(workingPdf.file.name)}-metadata.pdf`); + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } + }; + + const compressPdf = async (workingPdf: PdfInfo) => { + if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { + setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); + return; + } + + setBusy(true); + setMessage(''); + setCompressionResult(null); + setCompressionStatus(compressionMode === 'lossless' ? 'Optimizing PDF structure…' : 'Preparing pages…'); + + try { + const input = new Uint8Array(await workingPdf.file.arrayBuffer()); + let output: Uint8Array; + + if (compressionMode === 'lossless') { + const { createQpdfRunner } = await import('qpdf-run'); + const runner = await createQpdfRunner({ + workerUrl: '/qpdf/worker.js', + qpdfJsUrl: '/qpdf/qpdf.js', + wasmUrl: '/qpdf/qpdf.wasm', + timeoutMs: 90000, + }); + + try { + output = await runner.runOne({ + input, + inputName: 'input.pdf', + outputName: 'output.pdf', + args: losslessCompressionArgs(), + }); + } finally { + await runner.destroy(); + } + } else { + output = await rasterCompressPdf( + workingPdf.file, + compressionMode, + (current, total) => setCompressionStatus(`Rendering page ${current} of ${total}…`), + ); + } + + const downloaded = output.byteLength < input.byteLength; + setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); + + if (downloaded) { + downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); + } else { + setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); + } + } catch (err) { + const error = err as Error & { stderr?: string[] }; + const detail = Array.isArray(error.stderr) && error.stderr.length + ? `${error.message} — ${error.stderr.slice(-2).join(' ')}` + : error.message || String(err); + setMessage(`Compression failed: ${detail}`); + } finally { + setCompressionStatus(''); + setBusy(false); + } }; const imagesToPdf = async () => { @@ -212,30 +467,144 @@ export default function PdfToolkitPage() { page.drawImage(embedded, { x: (pageWidth - width) / 2, y: (pageHeight - height) / 2, width, height }); } downloadBytes(await doc.save(), 'images.pdf'); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const first = files.find(item => !item.error && item.pages > 0); - const readableCount = files.filter(item => !item.error && item.pages > 0).length; + const readableFiles = files.filter(item => !item.error && item.pages > 0); + const workingPdf = readableFiles.find(item => fileKey(item.file) === selectedKey) ?? null; + const operationHelp = operation === 'extract' + ? 'Creates a new PDF containing only the pages you list, in exactly that order. Use 5,2,1 to reorder or 8-6 to reverse a range.' + : operation === 'delete' + ? 'Creates a new PDF with the listed pages removed. Your original file is never modified.' + : 'Rotates only the listed pages by the angle you choose and downloads a new copy.'; + const compressionHelp = compressionMode === 'lossless' + ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Selectable text, links, forms and document structure are preserved.' + : compressionMode === 'balanced' + ? 'Renders each page at about 150 DPI and rebuilds the PDF with JPEG pages. Usually much smaller for scans, but selectable text, links, forms, bookmarks, accessibility structure and signatures are removed.' + : 'Renders each page at about 96 DPI with stronger JPEG compression. Best for small email/upload files when reduced sharpness and flattened pages are acceptable.'; - return + return
-

PDF files

-

Add several PDFs at once, or add them one at a time. New selections are appended to the current list.

+
+
+

PDF files

+

Add several PDFs at once, or add them one at a time. New selections are appended to the current list.

+
+ {files.length > 0 && } +
- void addPdfs(event.target.files)} className='mt-2 w-full rounded border p-3 disabled:opacity-50'/> - {files.length > 0 && <>

{files.length} file{files.length === 1 ? '' : 's'} loaded, {readableCount} readable.

{files.map((item, index) =>

{item.file.name}

{item.error ? 'Could not read this PDF' : `${item.pages} page${item.pages === 1 ? '' : 's'}`}

)}
} - + { void addPdfs(event.target.files); event.currentTarget.value = ''; }} className='mt-2 w-full rounded border p-3 disabled:opacity-50'/> + + {files.length > 0 && <> +

{files.length} file{files.length === 1 ? '' : 's'} loaded, {readableFiles.length} readable.

+
{files.map((item, index) =>
+
+

{item.file.name}

+

{item.error ? 'Could not read this PDF' : `${item.pages} page${item.pages === 1 ? '' : 's'} · ${formatBytes(item.file.size)}`}

+
+ + + +
)}
+ } + +
- {first &&

Page operations · {first.file.name}

Use page numbers such as 1-3,5,8-6. Descending ranges are useful for reversing pages.

setSelection(event.target.value)} className='rounded border p-2 font-mono' aria-label='Page selection'/>{operation === 'rotate' ? :
}
} + {readableFiles.length > 0 &&
+

PDF to edit or compress

+

Merge uses the full ordered list above. The tools below work on {readableFiles.length === 1 ? 'this PDF' : 'the selected PDF'} only.

+ + {readableFiles.length === 1 && workingPdf ?
+

{workingPdf.file.name}

+

{workingPdf.pages} page{workingPdf.pages === 1 ? '' : 's'} · {formatBytes(workingPdf.file.size)}

+
:
+ {readableFiles.map(item => { + const key = fileKey(item.file); + const selected = key === selectedKey; + return ; + })} +
} +
} + + {workingPdf &&
+

Compress PDF

+

All compression stays in your browser. Lossless preserves document structure; Balanced and Strong deliberately flatten pages for much larger reductions on scanned or image-heavy PDFs.

+
+ +

{compressionHelp}

+
+ + {compressionStatus &&

{compressionStatus}

} + {compressionResult &&
+
+
Original{formatBytes(compressionResult.original)}
+
Compressed result{formatBytes(compressionResult.compressed)}
+
Reduction{compressionResult.compressed < compressionResult.original ? `${((1 - compressionResult.compressed / compressionResult.original) * 100).toFixed(1)}%` : 'No reduction'}
+
+

{compressionResult.downloaded ? 'The smaller copy was downloaded automatically.' : 'The result was not smaller, so ToolStack kept your original instead of downloading a larger file.'}

+
} +
} - {first &&

Document metadata

} + {workingPdf &&
+

Edit pages in a PDF

+

Extract, reorder, delete or rotate pages in {workingPdf.file.name}. Enter pages such as 1-3,5,8-6. Descending ranges reverse page order.

+
+ setSelection(event.target.value)} className='rounded border p-2 font-mono' aria-label='Page selection'/> + + {operation === 'rotate' ? :
} +
+

{operationHelp}

+
+ + +
+

“Split every page to ZIP” creates one single-page PDF per page and packages all of them into a ZIP file. It does not use the page-selection field.

+
} -

Images → PDF

JPEG, PNG and browser-decodable images are fitted onto A4 portrait or landscape pages.

setImages(Array.from(event.target.files ?? []))} className='mt-4 w-full rounded border p-3'/>
+ {workingPdf &&
+

Document metadata · {workingPdf.file.name}

+
+ + + + +
+ +
} + +
+

Images → PDF

+

JPEG, PNG and browser-decodable images are fitted onto A4 portrait or landscape pages.

+ setImages(Array.from(event.target.files ?? []))} className='mt-4 w-full rounded border p-3'/> + +
-
PDF operations rewrite document structure. Existing digital signatures can become invalid, and unusual/encrypted PDFs may not be supported. This tool does not claim secure redaction or PDF compression.
+
PDF operations rewrite document structure. Existing digital signatures can become invalid, and unusual/encrypted PDFs may not be supported. Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/Strong modes flatten every page into a JPEG-backed page, so text selection, links, forms, bookmarks, accessibility structure and digital signatures are not preserved.
{message &&

{message}

}
; diff --git a/src/components/PwaRegistration.tsx b/src/components/PwaRegistration.tsx index 50ee199..db460ea 100644 --- a/src/components/PwaRegistration.tsx +++ b/src/components/PwaRegistration.tsx @@ -1,3 +1,43 @@ 'use client'; + import { useEffect } from 'react'; -export default function PwaRegistration() { useEffect(() => { if (process.env.NODE_ENV !== 'production' || !('serviceWorker' in navigator)) return; navigator.serviceWorker.register('/sw.js').catch(() => undefined); }, []); return null; } + +const TOOLSTACK_CACHE_PREFIX = 'toolstack-'; + +async function cleanupDevelopmentPwaState() { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(async registration => { + const workers = [registration.active, registration.waiting, registration.installing].filter(Boolean); + const isToolStackWorker = workers.some(worker => { + try { + return new URL(worker!.scriptURL).pathname === '/sw.js'; + } catch { + return false; + } + }); + if (isToolStackWorker) await registration.unregister(); + })); + + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.filter(key => key.startsWith(TOOLSTACK_CACHE_PREFIX)).map(key => caches.delete(key))); + } +} + +export default function PwaRegistration() { + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + + if (process.env.NODE_ENV !== 'production') { + void cleanupDevelopmentPwaState().catch(() => undefined); + return; + } + + void navigator.serviceWorker + .register('/sw.js', { updateViaCache: 'none' }) + .then(registration => registration.update()) + .catch(() => undefined); + }, []); + + return null; +} diff --git a/src/config/tools.ts b/src/config/tools.ts index 7b3f028..6607d32 100644 --- a/src/config/tools.ts +++ b/src/config/tools.ts @@ -38,7 +38,7 @@ export const TOOLS: Tool[] = [ tool('text-privacy-cleaner','Text Privacy Cleaner','Reveal or remove hidden Unicode, control and zero-width characters.','Privacy & Security',['privacy','text','unicode','cleaner','security'],{new:true}), tool('image-converter','Image Format Converter','Convert supported images to PNG, JPEG or WebP locally.','Images',['image','convert','png','jpeg','webp'],{new:true}), tool('file-encryption','File Encryption','Encrypt and decrypt files locally with AES-GCM and a password-derived key.','Privacy & Security',['privacy','encryption','aes','file','security'],{featured:true,new:true}), - tool('pdf-toolkit','PDF Toolkit','Merge, split, reorder, extract, delete and rotate PDF pages locally.','PDF & Documents',['pdf','merge','split','pages','documents','privacy'],{featured:true,new:true}), + tool('pdf-toolkit','PDF Toolkit','Compress, merge, split, reorder, extract, delete and rotate PDF pages locally.','PDF & Documents',['pdf','compress','merge','split','pages','documents','privacy'],{featured:true,new:true}), tool('file-signature-inspector','File Type & Signature Inspector','Inspect file magic bytes instead of trusting only the extension.','Privacy & Security',['file','signature','magic bytes','security','inspector'],{new:true}), tool('content-credentials','Content Credentials Inspector','Read embedded C2PA provenance metadata locally with bundled WebAssembly.','Privacy & Security',['c2pa','content credentials','provenance','metadata','privacy'],{featured:true,new:true}), tool('xml-toolbox','XML Toolbox','Validate, format, minify and convert XML to JSON locally.','Converters',['xml','json','format','validate','converter'],{new:true}),