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 };
|
|
|
|
|
|
}
|