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

306 lines
13 KiB
JavaScript
Raw Normal View History

feat(pdf-editor): a mark you placed is something you can still work on Everything the editor put on a page was final. A text stamp could not be corrected without deleting it and typing it again, nothing could be resized, and the only way to remove a mark was a Delete key nobody had been told about — the selection drew a dashed box and offered no action at all. Placing a stamp also left its tool armed, so the next click stamped a second copy. Marks are now editable objects. Selecting one gives it grab handles and a small bar pinned above it: delete and duplicate for anything, and for text an edit button, a size stepper and bold and italic. Double-clicking text reopens it for rewriting in place rather than adding a second one. Placing a text stamp or a signature drops straight back to the select tool with the new mark live, which is both what people expect and what puts it immediately within reach of a nudge. Resizing is one function over every mark type rather than a special case per kind: a handle drag produces a new bounding box, and the mark is mapped from its old box into that one. Text scales by font size instead of stretching its glyphs, signatures keep their aspect on a corner, and lines offer their two endpoints instead of a box that would let you stretch them in ways you never aimed at. A whole gesture lands on the undo stack as one step. Selecting a thin mark used to mean clicking its outline exactly — about one screen pixel. Each stroked mark now carries an invisible fat copy of itself purely to catch the pointer. New marks to go with it: underline and strike-through, which share the highlight's text-selection geometry and differ only in where the rule sits; a plain line; and a fill toggle for rectangles and ellipses. Bold and italic mean three more Helvetica variants embedded at save time, since a PDF treats them as separate fonts rather than as a style. Double-click is detected from the pointer stream rather than from a dblclick listener, because selecting a mark calls preventDefault() on the pointerdown and that suppresses the compatibility mouse events the browser would have synthesised the dblclick from.
2026-09-21 03:13:06 +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, 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" && 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 "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 "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,
});
// 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 };
}