/* ============================================================ pdf.js — single-page vector PDF writer for the print shop. Native PDF drawing ops (no embedded raster). Uses: • DeviceCMYK colour with a controllable rich black, so a print shop gets proper separations rather than RGB; • ExtGState soft-mask alpha (/ca,/CA) for true opacity on strokes and fills; • a base-14 Helvetica archival header (no font embedding). Geometry comes from the same scene model as every renderer. ============================================================ */ import { makeRng } from '../rng.js'; import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js'; const MARGIN = 0.02; export function buildPDF(scene, params, pageSize = 1728) { const scale = (pageSize / 2) * (1 - MARGIN); const cx = pageSize / 2, cy = pageSize / 2; const tx = (x) => (cx + x * scale); const ty = (y) => (cy - y * scale); // PDF y-up const u = pageSize / 1000; const inv = params.invert; // CMYK colours: warm cream paper, warm rich black ink (or swapped) const paperCMYK = inv ? '0.04 0.06 0.16 0.00' : '0.30 0.30 0.32 1.00'; const inkCMYK = inv ? '0.30 0.30 0.32 0.98' : '0.03 0.05 0.14 0.00'; // ExtGState alpha registry const gsSet = new Map(); const gs = (alpha) => { const k = Math.max(0, Math.min(100, Math.round(alpha * 100))); if (!gsSet.has(k)) gsSet.set(k, `GS${k}`); return `/GS${k} gs\n`; }; let c = ''; c += `${paperCMYK} k\n0 0 ${pageSize} ${pageSize} re f\n`; // chamber boundary if (params.showBoundary) { c += `q\n${gs(0.4)}${inkCMYK} K\n${(2 * u).toFixed(2)} w\n`; const bcx = cx, bcy = cy - pageSize * 0.35, br = pageSize * 0.45; const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15, steps = 90; for (let i = 0; i <= steps; i++) { const a = Math.PI + a1 + (a2 - a1) * (i / steps); const px = bcx + Math.cos(a) * br, py = bcy - Math.sin(a) * br; c += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`; } c += `S\nQ\n`; } // instrument geometry if (scene.instrument) { c += `q\n${inkCMYK} K\n1 J\n`; for (const l of scene.instrument.lines) c += `${gs(l.opacity)}${(l.width * u).toFixed(2)} w\n${tx(l.x1).toFixed(1)} ${ty(l.y1).toFixed(1)} m ${tx(l.x2).toFixed(1)} ${ty(l.y2).toFixed(1)} l S\n`; for (const a of scene.instrument.arcs) { c += `${gs(a.opacity)}${(a.width * u).toFixed(2)} w\n${tx(a.pts[0].x).toFixed(1)} ${ty(a.pts[0].y).toFixed(1)} m\n`; for (let i = 1; i < a.pts.length; i++) c += `${tx(a.pts[i].x).toFixed(1)} ${ty(a.pts[i].y).toFixed(1)} l\n`; c += `S\n`; } c += `Q\n`; } // continuity under-strokes (bucketed by alpha) c += `q\n${inkCMYK} K\n1 J\n1 j\n`; for (const t of scene.tracks) { if (t.pts.length < 2) continue; const iw = trackInkWeight(t); const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * t.weight; if (lw < 0.2 * u) continue; c += `${gs(0.14 * t.weight)}${lw.toFixed(2)} w\n`; c += `${tx(t.pts[0].x).toFixed(2)} ${ty(t.pts[0].y).toFixed(2)} m\n`; for (let i = 1; i < t.pts.length; i++) c += `${tx(t.pts[i].x).toFixed(2)} ${ty(t.pts[i].y).toFixed(2)} l\n`; c += `S\n`; } c += `Q\n`; // shock if (scene.shock) { const sh = scene.shock; const px = tx(sh.x), py = ty(sh.y); if (params.diskBubbles !== false && sh.bubbleStrokes) { // disk line work as bubbles (same method as tracks) const dRng = makeRng(params.seed, 'diskbubbles'); const dbk = new Map(); for (const stroke of sh.bubbleStrokes) { const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20; if (!dbk.has(key)) dbk.set(key, []); dbk.get(key).push(...sampleBubbles(stroke, params, dRng)); } c += `q\n${inkCMYK} k\n`; for (const [alpha, bubs] of dbk) { c += gs(alpha); for (const b of bubs) c += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n'; } c += `Q\n`; } else { c += `q\n${inkCMYK} K\n1 J\n`; for (const st of sh.striations) { const ix = px + Math.cos(st.a) * st.inner * scale, iy = py - Math.sin(st.a) * st.inner * scale; const ox = px + Math.cos(st.a) * st.outer * scale, oy = py - Math.sin(st.a) * st.outer * scale; c += `${gs(st.opacity)}${(st.width * u).toFixed(2)} w\n${ix.toFixed(1)} ${iy.toFixed(1)} m ${ox.toFixed(1)} ${oy.toFixed(1)} l S\n`; } for (const k of sh.core) { c += `${gs(k.opacity)}${(k.width * u).toFixed(2)} w\n${tx(k.x1).toFixed(1)} ${ty(k.y1).toFixed(1)} m ${tx(k.x2).toFixed(1)} ${ty(k.y2).toFixed(1)} l S\n`; } for (const ring of sh.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale); c += `Q\n`; } } // bubbles (bucketed by alpha) const buckets = new Map(); const bRng = makeRng(params.seed, 'bubbles'); for (const t of scene.tracks) { const key = Math.round(Math.min(1, 0.45 + t.weight * 0.5) * 20) / 20; if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(...sampleBubbles(t, params, bRng)); } c += `q\n${inkCMYK} k\n`; for (const [alpha, bubs] of buckets) { c += gs(alpha); for (const b of bubs) c += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n'; } c += `Q\n`; // plate damage const A = scene.artifacts; if (A) { c += `q\n${inkCMYK} K\n1 J\n`; for (const sc of A.scratches) c += `${gs(sc.opacity)}${(sc.width * u).toFixed(2)} w\n${tx(sc.x1).toFixed(1)} ${ty(sc.y1).toFixed(1)} m ${tx(sc.x2).toFixed(1)} ${ty(sc.y2).toFixed(1)} l S\n`; for (const hair of A.hairs) { c += `${gs(hair.opacity)}${(hair.width * u).toFixed(2)} w\n${tx(hair.pts[0].x).toFixed(1)} ${ty(hair.pts[0].y).toFixed(1)} m\n`; for (let i = 1; i < hair.pts.length; i++) c += `${tx(hair.pts[i].x).toFixed(1)} ${ty(hair.pts[i].y).toFixed(1)} l\n`; c += `S\n`; } for (const ring of A.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(tx(ring.x), ty(ring.y), ring.r * scale); c += `Q\nq\n${inkCMYK} k\n`; for (const sp of A.specks) c += gs(sp.opacity) + circlePath(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.5)) + 'f\n'; c += `Q\n`; } // fiducials if (params.showFiducials) { c += `q\n${gs(0.55)}${inkCMYK} K\n${(1.2 * u).toFixed(2)} w\n`; const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]]; const s = 8 * u; for (const [fx, fy] of fids) { const px = tx(fx), py = ty(fy); c += `${px - s} ${py} m ${px + s} ${py} l S\n${px} ${py - s} m ${px} ${py + s} l S\n`; } c += `Q\n`; } // archival header (Helvetica) if (params.showHeader) { const pad = 26 * u; c += `q\n${gs(0.6)}${inkCMYK} k\nBT\n/F0 ${(11 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 11 * u).toFixed(1)} Tm (${pdfStr(scene.lab.toUpperCase())}) Tj\nET\n`; c += `BT\n/F0 ${(9 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 27 * u).toFixed(1)} Tm (SEED ${pdfStr(params.seed)}) Tj\nET\n`; const fs = 10 * u; const rt = (txt, yoff) => { const wEst = txt.length * fs * 0.5; c += `BT\n/F0 ${fs.toFixed(1)} Tf\n1 0 0 1 ${(pageSize - pad - wEst).toFixed(1)} ${yoff.toFixed(1)} Tm (${pdfStr(txt)}) Tj\nET\n`; }; rt(`PLATE ${scene.plate}`, pad + 13 * u); rt(`EXPOSED ${scene.exposure}`, pad); c += `Q\n`; } // ExtGState dict let extg = ''; for (const [k, name] of gsSet) extg += `/${name} << /ca ${(k / 100).toFixed(2)} /CA ${(k / 100).toFixed(2)} >> `; return assemblePDF(c, pageSize, extg); } function circlePath(px, py, r) { const k = 0.5522847498 * r; return `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n` + `${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n` + `${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n` + `${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n` + `${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`; } function strokeCircle(px, py, r) { const k = 0.5522847498 * r; return `${(px - r).toFixed(2)} ${py.toFixed(2)} m ` + `${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c ` + `${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c ` + `${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c ` + `${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c S\n`; } function pdfStr(s) { return String(s).replace(/([()\\])/g, '\\$1'); } function assemblePDF(content, pageSize, extg) { const enc = new TextEncoder(); const contentBytes = enc.encode(content); let body = `%PDF-1.4\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`; const offsets = []; const addObj = (s) => { offsets.push(body.length); body += s; }; const resources = `<< /ExtGState << ${extg} >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>`; addObj(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n`); addObj(`2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n`); addObj(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageSize} ${pageSize}] /Contents 4 0 R /Resources ${resources} >>\nendobj\n`); addObj(`4 0 obj\n<< /Length ${contentBytes.length} >>\nstream\n${content}\nendstream\nendobj\n`); const xref = body.length; body += `xref\n0 5\n0000000000 65535 f \n`; for (const o of offsets) body += String(o).padStart(10, '0') + ' 00000 n \n'; body += `trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; return new Uint8Array(enc.encode(body)); }