// scripts/measure-pdf-compression.mjs — from tinytinytools.com // Served verbatim from the working tree. Method: https://tinytinytools.com/guides /** * The measurements behind /guides/what-pdf-compression-does. * * Runs the site's own compressor -- src/lib/pdf/compress.ts, unmodified -- across its three * presets, against three PDFs whose pages differ in kind. It is bundled with esbuild and * executed in a real Chromium, because that is where it runs in production: the thing doing * the work is pdf.js rendering each page to a canvas, and a Node reimplementation would be * measuring something else. * * Text extraction is measured before and after on the same document. The compressor's own * limitations say the output stops being selectable and searchable; this counts the * characters pdf.js can still pull out, which turns that from a claim into a number. * * Usage: node scripts/measure-pdf-compression.mjs */ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { build } from 'esbuild'; import { chromium } from 'playwright'; const dir = process.argv[2]; if (!dir) { console.error('Usage: node scripts/measure-pdf-compression.mjs '); process.exit(1); } // Inside the project, so esbuild resolves pdfjs-dist from node_modules the way the app does. const work = mkdtempSync(join(process.cwd(), '.measure-')); const entry = join(work, 'entry.js'); const bundle = join(work, 'bundle.js'); writeFileSync(entry, ` import { compressPdfBytes, COMPRESSION_LEVELS } from ${JSON.stringify(join(process.cwd(), 'src/lib/pdf/compress.ts'))}; import * as pdfjs from 'pdfjs-dist'; import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; /** * The adapters are supplied here rather than letting compress.ts fall back to its own. * Its browser default resolves the pdf.js worker through import.meta.url, which an IIFE * bundle does not have. Everything below the adapter boundary -- the rendering, the JPEG * encoding, the page assembly -- is the shipped code, unmodified. */ const adapters = { // pdf.js transfers the input buffer to its worker, which detaches it. The compressor is // called three times with the same document, so each call is handed its own copy -- // otherwise runs two and three fail on an empty buffer and the generic "could not be // read" message makes it look like the PDF is at fault. getDocument: async (bytes) => pdfjs.getDocument({ data: bytes.slice(), fontExtraProperties: true }), createCanvas: () => document.createElement('canvas'), }; /** Characters pdf.js can still extract, and how many pages yield any text at all. */ async function textProfile(bytes) { const task = pdfjs.getDocument({ data: bytes.slice(), fontExtraProperties: true }); const doc = await task.promise; let characters = 0, pagesWithText = 0; for (let n = 1; n <= doc.numPages; n++) { const content = await (await doc.getPage(n)).getTextContent(); const text = content.items.map(item => item.str ?? '').join('').trim(); characters += text.length; if (text.length > 0) pagesWithText++; } const pageCount = doc.numPages; await task.destroy(); return { characters, pagesWithText, pageCount }; } window.__levels = COMPRESSION_LEVELS; window.__run = async function (base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const before = await textProfile(bytes); const rows = []; for (const level of COMPRESSION_LEVELS) { const started = performance.now(); const output = await compressPdfBytes(bytes.slice(), { dpi: level.dpi, quality: level.quality }, adapters); const elapsed = performance.now() - started; rows.push({ level: level.id, dpi: level.dpi, quality: level.quality, size: output.length, ms: Math.round(elapsed), text: await textProfile(output), }); } return { originalSize: bytes.length, before, rows }; }; `); await build({ entryPoints: [entry], outfile: bundle, bundle: true, format: 'iife', platform: 'browser', target: 'es2022', loader: { '.ts': 'ts' }, plugins: [{ name: 'worker-url', setup(b) { // pdf.js ships its worker as a separate module; inline it as a blob URL so the // bundle is a single file with no network fetch inside the headless browser. b.onResolve({ filter: /\?url$/ }, args => ({ path: args.path, namespace: 'worker-url' })); b.onLoad({ filter: /.*/, namespace: 'worker-url' }, async () => { const workerPath = join(process.cwd(), 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs'); const source = readFileSync(workerPath, 'utf8'); return { contents: `export default URL.createObjectURL(new Blob([${JSON.stringify(source)}], { type: 'text/javascript' }));`, loader: 'js', }; }); }, }], }); const browser = await chromium.launch(); const page = await browser.newPage(); await page.setContent('compress'); await page.addScriptTag({ path: bundle }); const results = {}; for (const name of readdirSync(dir).filter(f => f.endsWith('.pdf')).sort()) { const bytes = readFileSync(join(dir, name)); results[name] = await page.evaluate(b => window.__run(b), bytes.toString('base64')); } const levels = await page.evaluate(() => window.__levels); await browser.close(); rmSync(work, { recursive: true, force: true }); console.log(JSON.stringify({ levels, results }, null, 2));