// Turning the editing session back into a PDF, with pdf-lib. // // The source bytes are never mutated. Every save re-loads the original and // replays the session onto that fresh copy, so a botched save cannot poison // the next one and the file on disk stays whatever it was. // // Step order is load-bearing: // // 1. form values — before anything strips a widget // 2. flatten — replaces a redacted page's content stream, which // would wipe marks drawn in step 3 // 3. marks — drawn against ORIGINAL page indices, which are still // valid because nothing has moved yet // 4. page tree — reorder and delete last, so step 3's indices held // 5. rotation — independent of order, done in the same pass // // Marks are drawn with pdf-lib's operator helpers rather than drawSvgPath. // drawSvgPath takes an SVG-shaped path with y running downwards from an // anchor, so every mark would need a second coordinate convention and a // matching inverse in the overlay. Raw operators take the user-space points we // already store, unchanged. import { PDFDocument, PDFName, StandardFonts, rgb, degrees, pushGraphicsState, popGraphicsState, moveTo, lineTo, stroke, fill, setStrokingColor, setFillingColor, setLineWidth, setLineCap, setLineJoin, LineCapStyle, LineJoinStyle, } from "../vendor/pdf-lib/pdf-lib.esm.min.js"; import { arrowBarbs, editLayout, signatureStrokes, textLayout, RULE_OFFSET } from "./shape.js"; /** #rrggbb -> pdf-lib rgb(). */ function colorOf(hex) { const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "#000000")); const n = m ? parseInt(m[1], 16) : 0; return rgb(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); } // Helvetica is a WinAnsi font: it simply cannot encode most of Unicode. // Embedding a font that could would mean shipping a several-MB typeface for a // feature most stamps never need, so instead we substitute and say so. const WIN_ANSI_OK = /^[\x20-\x7E\xA0-\xFF‘’“”–—•€…‰‹›ŒœŠšŸŽžƒˆ˜™†‡‰]*$/; export function winAnsiSafe(text) { const s = String(text ?? ""); if (WIN_ANSI_OK.test(s)) return { text: s, dropped: 0 }; let dropped = 0; const out = Array.from(s).map((ch) => { if (WIN_ANSI_OK.test(ch)) return ch; dropped++; return "?"; }).join(""); return { text: out, dropped }; } /** True when any text stamp carries characters Helvetica cannot write. */ export function unencodableStamps(model) { return model.annots.filter((a) => (a.kind === "text" || a.kind === "textedit") && winAnsiSafe(a.text).dropped > 0).length; } /** How many runs were replaced in place, for the save summary. */ export function replacedRuns(model) { return model.annots.filter((a) => a.kind === "textedit").length; } // ---- mark drawing ----------------------------------------------------- function strokePolyline(page, pts, color, width) { if (pts.length < 2) return; const ops = [ pushGraphicsState(), setStrokingColor(colorOf(color)), setLineWidth(width), setLineCap(LineCapStyle.Round), setLineJoin(LineJoinStyle.Round), moveTo(pts[0][0], pts[0][1]), ]; for (let i = 1; i < pts.length; i++) ops.push(lineTo(pts[i][0], pts[i][1])); ops.push(stroke(), popGraphicsState()); page.pushOperators(...ops); } function fillRect(page, x, y, w, h, color, opacity) { if (opacity != null && opacity < 1) { // drawRectangle is the only path that can set a fill opacity, because it // registers the ExtGState for us. page.drawRectangle({ x, y, width: w, height: h, color: colorOf(color), opacity, borderWidth: 0 }); return; } page.pushOperators( pushGraphicsState(), setFillingColor(colorOf(color)), moveTo(x, y), lineTo(x + w, y), lineTo(x + w, y + h), lineTo(x, y + h), fill(), popGraphicsState()); } function drawMark(page, a, font) { switch (a.kind) { case "highlight": for (const r of a.rects || []) { if (r.w <= 0 || r.h <= 0) continue; fillRect(page, r.x, r.y, r.w, r.h, a.color, a.opacity ?? 0.38); } return; case "pen": strokePolyline(page, a.pts || [], a.color, a.width || 2); return; case "underline": case "strikeout": { // Same offset the overlay drew it at, from the same constant. const frac = RULE_OFFSET[a.kind]; const w = Math.max(0.5, a.width || 1.4); for (const r of a.rects || []) { const y = r.y + r.h * frac; strokePolyline(page, [[r.x, y], [r.x + r.w, y]], a.color, w); } return; } case "rect": page.drawRectangle({ x: a.x, y: a.y, width: a.w, height: a.h, borderColor: colorOf(a.color), borderWidth: a.width || 2, color: a.fill ? colorOf(a.color) : undefined, opacity: a.fill ? (a.opacity ?? 0.25) : 0 }); return; case "ellipse": page.drawEllipse({ x: a.x + a.w / 2, y: a.y + a.h / 2, xScale: Math.max(0.5, a.w / 2), yScale: Math.max(0.5, a.h / 2), borderColor: colorOf(a.color), borderWidth: a.width || 2, color: a.fill ? colorOf(a.color) : undefined, opacity: a.fill ? (a.opacity ?? 0.25) : 0 }); return; case "line": strokePolyline(page, [[a.x1, a.y1], [a.x2, a.y2]], a.color, a.width || 2); return; case "arrow": { const w = a.width || 2; strokePolyline(page, [[a.x1, a.y1], [a.x2, a.y2]], a.color, w); const b = arrowBarbs(a.x1, a.y1, a.x2, a.y2, w); strokePolyline(page, [[b[0].x, b[0].y], [a.x2, a.y2], [b[1].x, b[1].y]], a.color, w); return; } case "text": { const size = a.size || 12; const f = typeof font === "function" ? font(a) : font; for (const line of textLayout(a)) { const { text } = winAnsiSafe(line.text); if (!text) continue; page.drawText(text, { x: line.x, y: line.y, size, font: f, color: colorOf(a.color) }); } return; } case "textedit": { // Cover the original run in the page's own background colour, then set // the replacement on the run's own baseline. The original glyphs are // still in the content stream underneath — this hides them, it does not // delete them, which is the same honesty the redact tool states out // loud. Anyone who needs them gone should redact instead. fillRect(page, a.x, a.y, a.w, a.h, a.cover || "#ffffff", 1); const size = a.size || 12; const f = typeof font === "function" ? font(a) : font; for (const line of editLayout(a)) { const { text } = winAnsiSafe(line.text); if (!text) continue; page.drawText(text, { x: line.x, y: line.y, size, font: f, color: colorOf(a.color || "#000000") }); } return; } case "signature": for (const s of signatureStrokes(a)) strokePolyline(page, s, a.color, a.width || 1.6); return; case "redact": // Opaque, no border, no transparency — a redaction that can be seen // through is not a redaction. fillRect(page, a.x, a.y, a.w, a.h, "#000000", 1); return; default: console.warn("save: unknown mark kind", a.kind); } } // ---- form fields ------------------------------------------------------ function applyFormValues(doc, values, font, report) { if (!values.length) return; let form; try { form = doc.getForm(); } catch (e) { report.warn.push("This file's form could not be opened, so field values were not saved."); return; } const byName = new Map(); try { for (const f of form.getFields()) byName.set(f.getName(), f); } catch (e) { report.warn.push("This file's field list could not be read."); return; } let wrote = 0, missed = 0; for (const { name, value } of values) { const f = byName.get(name); if (!f) { missed++; continue; } try { if (typeof f.setText === "function") { const { text, dropped } = winAnsiSafe(value == null ? "" : String(value)); if (dropped) report.substituted += dropped; f.setText(text); } else if (typeof f.check === "function") { const on = value === true || value === "On" || (typeof value === "string" && value !== "Off" && value !== ""); if (on) f.check(); else f.uncheck(); } else if (typeof f.select === "function") { if (value == null || value === "") { if (typeof f.clear === "function") f.clear(); } else f.select(Array.isArray(value) ? value[0] : String(value)); } else { missed++; continue; } // Per-field so one field Helvetica cannot draw does not abort the save. try { if (typeof f.updateAppearances === "function") f.updateAppearances(font); } catch {} wrote++; } catch (e) { missed++; console.warn(`form field "${name}":`, e?.message || e); } } report.fieldsWritten = wrote; if (missed) report.warn.push(`${missed} field${missed === 1 ? "" : "s"} could not be written back.`); } // ---- flattening ------------------------------------------------------- // A redaction box that only paints over text is theatre: the text is still in // the content stream and still comes out of a copy-paste. The only fix that // pdf-lib alone can reach is to replace the page's whole content stream with a // picture of it, which is what this does. Annotations (form widgets, links) are // deliberately KEPT — they are not part of the content stream, so they were // never the leak, and dropping them would quietly break a form. async function flattenPage(doc, page, pngBytes, report) { const img = await doc.embedPng(pngBytes); const box = page.getCropBox(); // Empty array, not a fresh empty stream: pdf-lib appends its own stream to // this array on the first draw call, so the original marks are simply gone. page.node.set(PDFName.of("Contents"), doc.context.obj([])); page.drawImage(img, { x: box.x, y: box.y, width: box.width, height: box.height }); report.flattened++; } // ---- page tree -------------------------------------------------------- function rebuildPages(doc, originalPages, srcOrder) { const wanted = srcOrder.map((i) => originalPages[i]).filter(Boolean); if (!wanted.length) throw new Error("a PDF needs at least one page"); for (let i = doc.getPageCount() - 1; i >= 0; i--) doc.removePage(i); // addPage re-registers a page whose ref was dropped by removePage, so the // same page objects (and the widget annotations that point at them) come // back intact in the new order. for (const p of wanted) doc.addPage(p); } /** * @param {object} o * @param {Uint8Array} o.originalBytes untouched source * @param {DocModel} o.model * @param {{name:string,value:any}[]} o.formValues harvested from pdf.js * @param {boolean} o.flatten rebuild redacted pages as images * @param {(uid:string)=>Promise} o.rasterize renders one page to PNG * @returns {Promise<{bytes:Uint8Array, report:object}>} */ export async function buildPdf({ originalBytes, model, formValues = [], flatten = true, rasterize }) { const report = { flattened: 0, marks: 0, fieldsWritten: 0, substituted: 0, warn: [] }; const doc = await PDFDocument.load(originalBytes, { ignoreEncryption: true, // Leave the file's own Producer/ModDate alone. A privacy-minded browser // should not stamp "this was edited, with this library, at this time" into // every document that passes through it. updateMetadata: false, }); // Four variants, because bold and italic are separate fonts in a PDF rather // than a style applied to one. Embedded once up front; unused ones cost a // few hundred bytes of font dictionary and nothing else. const fonts = { regular: await doc.embedFont(StandardFonts.Helvetica), bold: await doc.embedFont(StandardFonts.HelveticaBold), italic: await doc.embedFont(StandardFonts.HelveticaOblique), boldItalic: await doc.embedFont(StandardFonts.HelveticaBoldOblique), }; const font = fonts.regular; const fontFor = (a) => a.bold && a.italic ? fonts.boldItalic : a.bold ? fonts.bold : a.italic ? fonts.italic : fonts.regular; const originalPages = doc.getPages(); // 1. form values applyFormValues(doc, formValues, font, report); // 2. flatten redacted pages const redacted = model.redactedPages(); if (flatten && redacted.size && typeof rasterize === "function") { for (const uid of redacted) { const src = model.page(uid).src; const page = originalPages[src]; if (!page) continue; try { await flattenPage(doc, page, await rasterize(uid), report); } catch (e) { report.warn.push(`Page ${src + 1} could not be rebuilt as an image; its redaction only covers the text.`); console.warn("flatten failed:", e); } } } else if (redacted.size && !flatten) { report.warn.push("Redactions cover the text but do not remove it — it is still extractable from the saved file."); } // 3. marks, against original indices for (const uid of model.visible()) { const page = originalPages[model.page(uid).src]; if (!page) continue; // Redaction boxes go on top of everything else on their page. const marks = model.annotsFor(uid); for (const a of marks) if (a.kind !== "redact") { drawMark(page, a, fontFor); report.marks++; } for (const a of marks) if (a.kind === "redact") { drawMark(page, a, fontFor); report.marks++; } } // 4. page tree const srcOrder = model.visible().map((uid) => model.page(uid).src); if (model.pagesChanged()) { rebuildPages(doc, originalPages, srcOrder); report.pagesRebuilt = true; } // 5. rotation — applied to the page objects, so order does not matter for (const uid of model.visible()) { const extra = model.extraRotation(uid); if (!extra) continue; const page = originalPages[model.page(uid).src]; if (!page) continue; try { page.setRotation(degrees((page.getRotation().angle + extra) % 360)); } catch (e) { console.warn("setRotation:", e?.message); } } const bytes = await doc.save({ useObjectStreams: true }); return { bytes, report }; }