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.
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
// 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 };
|
||
}
|