theseus/bundled-addons/pdf-editor/lib/save.js

275 lines
12 KiB
JavaScript
Raw Normal View History

feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
// 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, signatureStrokes, textLayout } 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" && winAnsiSafe(a.text).dropped > 0).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 "rect":
page.drawRectangle({ x: a.x, y: a.y, width: a.w, height: a.h,
borderColor: colorOf(a.color), borderWidth: a.width || 2, opacity: 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, opacity: 0 });
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;
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, color: colorOf(a.color) });
}
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<Uint8Array>} 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,
});
const font = await doc.embedFont(StandardFonts.Helvetica);
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, font); report.marks++; }
for (const a of marks) if (a.kind === "redact") { drawMark(page, a, font); 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 };
}