Until now "editing" a PDF here meant laying things over it. You could put a word on top of a word, but the document underneath never changed, and the result read like a sticker because it was one. This adds the thing the word Edit actually promises: click a line of the document's text, type different words, and they land where the old ones were, in the old size and the old colour. The position and size come from pdf.js's text layer, which has already placed a span over every run and carries that run's size in unscaled PDF points — so the size is right whatever the zoom, which reading it off the rendered box would not be. The colours come from the rendered page, because nothing in the text API reports them: the background is the average of the most common colour bucket in the run's box, since type is a minority of the pixels even when it is dense, and the ink is whatever sits furthest from that background. On the test fixture it recovers the marker's red exactly. Two things that look like details and are not. The bucket only chooses WHICH pixels are background; the colour itself is their average, because rebuilding it from the bucket index rounds white down to #f8f8f8 and a not-quite-white patch on a white page is a visible seam. And the cover reaches below the baseline by a quarter of the font size, because pdf.js sizes its spans to the em box: cut the cover to the span and every descender in the original line survives as a little hook under the replacement. A replacement is a cover plus text, so it is a mark like any other — movable, resizable, undoable, and rendered on screen from the same numbers the writer uses, which is what makes the preview trustworthy. Said plainly in the dialog and again in the save summary: this hides the original, it does not remove it. The old glyphs are still in the content stream underneath. Redact is the tool that takes text away, and it says so too.
327 lines
14 KiB
JavaScript
327 lines
14 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, 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<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 };
|
||
}
|