// scripts/make-pdf-test-documents.mjs — from tinytinytools.com // Served verbatim from the working tree. Method: https://tinytinytools.com/guides /** * Builds the test PDFs used by /guides/what-pdf-compression-does. * * Three documents that stress the compressor differently, because the honest answer to * "how much will this shrink my PDF" depends almost entirely on what is on the pages: * * text-report.pdf Text drawn with embedded standard fonts. No images at all. * scanned.pdf Every page is one full-page photograph, as a phone scan would be. * mixed.pdf Text pages and photo pages in the same document. * * Built with pdf-lib, which is the same library the compressor writes its output with, and * deterministic: no timestamps, no random content, so a reader regenerates identical files * and can check every number in the guide against their own run. * * Usage: node scripts/make-pdf-test-documents.mjs */ import { mkdirSync, writeFileSync } from 'node:fs'; import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import { chromium } from 'playwright'; const out = process.argv[2]; if (!out) { console.error('Usage: node scripts/make-pdf-test-documents.mjs '); process.exit(1); } mkdirSync(out, { recursive: true }); const PAGE = [595.28, 841.89]; // A4 in points. const PAGES = 8; /** * A4 at 200 DPI, which is what a document scanner or a phone scanning app produces. * * The first version of this script embedded one photograph and drew it on all eight pages. * pdf-lib stores an embedded image once and references it, so the whole "scan" came to * 70 KB -- around a hundredth of a real one -- and every compression result measured * against it was meaningless. Each page now gets its own distinct image. */ const SCAN_WIDTH = 1654; const SCAN_HEIGHT = 2339; /** Deterministic filler with realistic word and sentence shape. */ const WORDS = ('the quarterly review confirms that processing remains local to the browser and ' + 'no document leaves the device during conversion which is the property this report exists ' + 'to record together with the measured page counts and the sizes observed at each setting') .split(' '); function paragraph(seed, wordCount) { const words = []; let state = seed; for (let i = 0; i < wordCount; i++) { state = (state * 1103515245 + 12345) & 0x7fffffff; words.push(WORDS[state % WORDS.length]); } const text = words.join(' '); return text.charAt(0).toUpperCase() + text.slice(1) + '.'; } function wrap(text, perLine) { const words = text.split(' '); const lines = []; for (let i = 0; i < words.length; i += perLine) lines.push(words.slice(i, i + perLine).join(' ')); return lines; } async function textReport() { const doc = await PDFDocument.create(); const body = await doc.embedFont(StandardFonts.Helvetica); const heading = await doc.embedFont(StandardFonts.HelveticaBold); for (let p = 0; p < PAGES; p++) { const page = doc.addPage(PAGE); page.drawText(`Section ${p + 1}: processing and retention`, { x: 56, y: 780, size: 16, font: heading, color: rgb(0.07, 0.14, 0.18), }); let y = 744; for (let block = 0; block < 6; block++) { for (const line of wrap(paragraph(p * 31 + block, 58), 11)) { page.drawText(line, { x: 56, y, size: 10.5, font: body, color: rgb(0.2, 0.24, 0.27) }); y -= 15.5; } y -= 10; } page.drawText(String(p + 1), { x: 296, y: 32, size: 9, font: body, color: rgb(0.45, 0.5, 0.53) }); } return doc.save(); } async function scanned(images) { const doc = await PDFDocument.create(); for (let p = 0; p < PAGES; p++) { const image = await doc.embedJpg(images[p]); const page = doc.addPage(PAGE); page.drawImage(image, { x: 0, y: 0, width: PAGE[0], height: PAGE[1] }); } return doc.save(); } async function mixed(images) { const doc = await PDFDocument.create(); const body = await doc.embedFont(StandardFonts.Helvetica); for (let p = 0; p < PAGES; p++) { const page = doc.addPage(PAGE); if (p % 2 === 0) { let y = 760; for (let block = 0; block < 6; block++) { for (const line of wrap(paragraph(p * 17 + block, 58), 11)) { page.drawText(line, { x: 56, y, size: 10.5, font: body, color: rgb(0.2, 0.24, 0.27) }); y -= 15.5; } y -= 10; } } else { const image = await doc.embedJpg(images[p]); page.drawImage(image, { x: 40, y: 240, width: PAGE[0] - 80, height: 360 }); page.drawText('Figure ' + ((p + 1) / 2), { x: 56, y: 210, size: 10.5, font: body }); } } return doc.save(); } /** * One distinct scan-like page image per page, encoded by a real browser. * * Deterministic: a seeded generator, so regenerating produces identical documents. */ async function scanPages(count) { const browser = await chromium.launch(); const page = await browser.newPage(); await page.setContent(''); const images = await page.evaluate(async ({ count, width, height }) => { const rng = seed => () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; const out = []; for (let p = 0; p < count; p++) { const random = rng(p + 1); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); // Paper, with the uneven lighting and speckle a real scan carries. That texture is // the reason a scan compresses the way it does, so it has to be here. ctx.fillStyle = '#f7f4ee'; ctx.fillRect(0, 0, width, height); const grad = ctx.createLinearGradient(0, 0, width, height); grad.addColorStop(0, 'rgba(0,0,0,0.05)'); grad.addColorStop(0.5, 'rgba(0,0,0,0)'); grad.addColorStop(1, 'rgba(0,0,0,0.07)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); const speckle = ctx.getImageData(0, 0, width, height); for (let i = 0; i < speckle.data.length; i += 4) { const n = (random() - 0.5) * 16; speckle.data[i] += n; speckle.data[i + 1] += n; speckle.data[i + 2] += n; } ctx.putImageData(speckle, 0, 0); // Scanned text: dark marks of varying length, not real glyphs, but the same shape // of high-contrast detail on paper that decides how the codec behaves. ctx.fillStyle = '#1d2733'; ctx.font = '600 44px serif'; ctx.fillText('Scanned document ' + (p + 1), 150, 190); ctx.font = '400 30px serif'; for (let line = 0; line < 46; line++) { let x = 150; const y = 300 + line * 42; while (x < width - 220) { const w = 40 + Math.floor(random() * 150); ctx.fillRect(x, y - 20, w, 22); x += w + 16 + Math.floor(random() * 14); } } const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85)); const buf = new Uint8Array(await blob.arrayBuffer()); let binary = ''; for (let i = 0; i < buf.length; i++) binary += String.fromCharCode(buf[i]); out.push(btoa(binary)); } return out; }, { count, width: SCAN_WIDTH, height: SCAN_HEIGHT }); await browser.close(); return images.map(b64 => Uint8Array.from(Buffer.from(b64, 'base64'))); } const pageImages = await scanPages(PAGES); for (const [name, bytes] of [ ['text-report.pdf', await textReport()], ['scanned.pdf', await scanned(pageImages)], ['mixed.pdf', await mixed(pageImages)], ]) { writeFileSync(`${out}/${name}`, bytes); console.log(`${name.padEnd(18)} ${String(bytes.length).padStart(9)} bytes ${PAGES} pages`); }