theseus/bundled-addons/pdf-editor/editor.js
Local Dev 62460cd3aa feat(pdf-editor): select an area, and treat what it caught as one thing
Drag on empty page and a rubber band gathers every mark it sweeps over.
Touching counts rather than enclosing: a band you have to draw right
around a long arrow is a band you draw twice. Shift-click adds or removes
one, Ctrl+A takes the page.

What the group can then do is move, restyle, duplicate and delete, each as
a single undo step — six marks deleted is one thing the user did, so it
has to be one thing to undo. The selection bar offers only what is true of
every member: a group of shapes gets Fill, a group of stamps gets size and
weight, a mixed group gets neither, and none of them gets Edit, which
needs one mark to put a caret in.

No resize handles on a group. Stretching a mixed selection means deciding
what a stretch does to text, which scales by font size, and to a line,
which has no box at all; until there is an answer worth defending,
offering the grip would promise something this cannot keep.

Each member is outlined as well as the group, because a band that caught
one more mark than you meant is worth seeing before you press Delete.

A group lives on one page. A mark carries its page, and PDF user space
means nothing across two of them, so a band selects within the page it
was drawn on.

One trap found on the way: starting the band on a press means calling
preventDefault, which is also what gives a form field its caret — so the
band now keeps its hands off the annotation layer, and typing into a PDF
form still works.
2026-09-27 13:45:04 +02:00

1524 lines
63 KiB
JavaScript

// PDF Editor — the page. A full Theseus tab, opened through the "open-tab"
// capability, talking to the add-on's Node half over window.silentmode.
//
// Boot order matters: pdf.js's viewer components are published as a bundle
// that reads its API off globalThis.pdfjsLib rather than importing it, so the
// core has to be imported and parked on the global BEFORE the components
// bundle is pulled in.
//
// Find is deliberately absent from this file. Theseus captures Ctrl+F at the
// app level (main.js's before-input-event handler) and drives Chromium's own
// findInPage over the tab — so the browser's find bar already searches this
// document, with the count and next/previous the user knows from every other
// page. A second find UI inside the tab would be the worse one. What this file
// owes that arrangement is a text layer for every page, drawn or not, which is
// why lib/view.js keeps its own (see the comment there).
import { DocModel } from "./lib/model.js";
import { PageStrip } from "./lib/view.js";
import { Rail } from "./lib/rail.js";
import { Tools } from "./lib/tools.js";
import { SignaturePad } from "./lib/signature.js";
import { InlineText } from "./lib/inline.js";
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont, unionOf } from "./lib/shape.js";
import { reflow } from "./lib/reflow.js";
import { buildDocx } from "./lib/docx.js";
import { imagesToPdf, mergePdfs } from "./lib/create.js";
const $ = (id) => document.getElementById(id);
const sm = window.silentmode || null;
const FLATTEN_DPI = 150; // raster resolution when rebuilding a redacted page
// ---- boot pdf.js ------------------------------------------------------
let pdfjsLib = null;
async function bootPdfjs() {
if (pdfjsLib) return pdfjsLib;
const core = await import("./vendor/pdfjs/pdf.min.mjs");
core.GlobalWorkerOptions.workerSrc = new URL("./vendor/pdfjs/pdf.worker.min.mjs", import.meta.url).href;
// pdf_viewer.mjs is built with the core as an external and reads it off the
// global at module-evaluation time, so this assignment has to happen first.
globalThis.pdfjsLib = core;
const viewer = await import("./vendor/pdfjs/web/pdf_viewer.mjs");
// A module namespace object is frozen, so the components go on a wrapper
// rather than onto `core` itself.
pdfjsLib = {
getDocument: core.getDocument,
TextLayer: core.TextLayer,
setLayerDimensions: core.setLayerDimensions,
AnnotationMode: core.AnnotationMode,
viewer,
};
return pdfjsLib;
}
// ---- session ----------------------------------------------------------
const session = {
bytes: null, // Uint8Array, the untouched original
pdf: null, // pdf.js PDFDocumentProxy
model: null,
strip: null,
rail: null,
tools: null,
scratchId: null, // set when the document came from the add-on's scratch dir
name: null, // null until something is loaded; the chrome says so
};
// ---- chrome -----------------------------------------------------------
function toast(msg, err) {
const t = $("toast");
t.textContent = msg;
t.classList.toggle("err", !!err);
t.classList.add("on");
clearTimeout(toast._t);
toast._t = setTimeout(() => t.classList.remove("on"), err ? 4200 : 2000);
}
function status(msg, err) {
const s = $("status");
s.textContent = msg;
s.classList.toggle("err", !!err);
}
function setBusy(on, why) {
document.body.dataset.busy = on ? "1" : "";
if (on && why) status(why);
}
function paintChrome() {
const m = session.model;
$("save").disabled = !m;
$("convert").disabled = !m;
$("undo").disabled = !m || !m.canUndo();
$("redo").disabled = !m || !m.canRedo();
const nameEl = $("docname");
nameEl.textContent = session.name || "No document";
nameEl.classList.toggle("dirty", !!m && m.isDirty());
document.body.dataset.empty = m ? "" : "1";
}
function paintPageInfo() {
const m = session.model, strip = session.strip;
if (!m || !strip) { $("pageinfo").textContent = ""; return; }
const vis = m.visible();
const at = strip.current ? vis.indexOf(strip.current) + 1 : 1;
const pct = Math.round(strip.scale * 100);
$("pageinfo").textContent = `Page ${at} of ${vis.length} · ${pct}%`;
}
// ---- modals -----------------------------------------------------------
let modalResolve = null;
function openModal(id) {
for (const m of document.querySelectorAll(".modal")) m.hidden = true;
$(id).hidden = false;
$("scrim").hidden = false;
return new Promise((res) => { modalResolve = res; });
}
function closeModal(value) {
$("scrim").hidden = true;
for (const m of document.querySelectorAll(".modal")) m.hidden = true;
const r = modalResolve; modalResolve = null;
r?.(value);
}
$("scrim").addEventListener("pointerdown", (e) => { if (e.target === $("scrim")) closeModal(null); });
// ---- loading ----------------------------------------------------------
async function loadBytes(bytes, name, scratchId = null) {
if (session.pdf) await teardown();
// pdf.js TRANSFERS the buffer it is given to its worker, which detaches our
// view of it — and pdf-lib needs those same bytes at save time. Keep the
// original and hand the worker a copy.
session.bytes = bytes;
session.name = name;
session.scratchId = scratchId;
setBusy(true, "Opening…");
const lib = await bootPdfjs();
const task = lib.getDocument({
data: bytes.slice(),
standardFontDataUrl: "vendor/pdfjs/standard_fonts/",
wasmUrl: "vendor/pdfjs/wasm/",
iccUrl: "vendor/pdfjs/iccs/",
// XFA forms render best-effort. pdf-lib cannot write XFA back, so a
// dynamic XFA form is view-and-print only here; see the warning below.
enableXfa: true,
enableScripting: false,
isEvalSupported: false,
});
let pdf;
try { pdf = await task.promise; }
catch (e) {
setBusy(false);
status(`Could not open ${name}: ${e?.message || e}`, true);
toast(`Could not open that PDF: ${e?.message || e}`, true);
return false;
}
session.pdf = pdf;
const pageInfo = [];
for (let i = 1; i <= pdf.numPages; i++) {
const p = await pdf.getPage(i);
const vp = p.getViewport({ scale: 1, rotation: 0 });
pageInfo.push({ width: vp.width, height: vp.height, rotate: p.rotate });
}
session.model = new DocModel({ name, pageCount: pdf.numPages, pageInfo });
const eventBus = new lib.viewer.EventBus();
const linkService = new lib.viewer.PDFLinkService({
eventBus, externalLinkTarget: lib.viewer.LinkTarget.BLANK,
});
const layerProperties = {
annotationEditorUIManager: null,
annotationStorage: pdf.annotationStorage,
downloadManager: null,
enableScripting: false,
fieldObjectsPromise: pdf.getFieldObjects().catch(() => null),
findController: null,
hasJSActionsPromise: pdf.hasJSActions().catch(() => false),
linkService,
};
session.strip = new PageStrip({
container: $("viewerContainer"), viewer: $("viewer"),
pdfjsLib: lib, pdf, model: session.model, eventBus, layerProperties,
});
await session.strip.build();
// A shim is all PDFLinkService needs to make a PDF's own table-of-contents
// links jump to the right page.
linkService.setViewer(linkShim(session.strip, session.model));
linkService.setDocument(pdf, null);
session.rail = new Rail({
list: $("rail-list"), countEl: $("rail-count"),
model: session.model, strip: session.strip,
host: { rotate: onRotate, toggleDelete: onToggleDelete, reorder: onReorder },
});
session.rail.build();
session.tools = new Tools({
strip: session.strip, model: session.model,
host: {
toast,
onChange: () => { paintChrome(); },
selectTool,
onSelectionChange: paintSelectionBar,
beginText,
editText: onTextModal,
editRun: onEditRun,
chooseSignature,
signature: () => savedSignature,
},
});
session.tools.setTool("select");
session.strip.onCurrentChange((uid) => { session.rail.markCurrent(uid); paintPageInfo(); });
session.model.onChange(() => { paintChrome(); });
setBusy(false);
const forms = await countFields(pdf);
const notes = [`${pdf.numPages} page${pdf.numPages === 1 ? "" : "s"}`];
if (forms.fields) notes.push(`${forms.fields} form field${forms.fields === 1 ? "" : "s"}`);
if (forms.xfa) notes.push("XFA form — fields can be filled here but cannot be saved back");
status(notes.join(" · "), !!forms.xfa);
paintChrome();
paintPageInfo();
return true;
}
async function countFields(pdf) {
let fields = 0, xfa = false;
try {
const fo = await pdf.getFieldObjects();
if (fo) for (const v of fo.values()) fields += v.length;
} catch {}
try { xfa = !!(await pdf.getMetadata())?.info?.IsXFAPresent; } catch {}
return { fields, xfa };
}
function linkShim(strip, model) {
return {
get pagesCount() { return model.visible().length; },
get currentPageNumber() { return Math.max(1, model.visible().indexOf(strip.current) + 1); },
set currentPageNumber(n) { const uid = model.visible()[n - 1]; if (uid) strip.scrollTo(uid); },
get currentScaleValue() { return strip.scaleMode; },
set currentScaleValue(v) { strip.setScaleMode(Number.isFinite(+v) ? +v : v); },
get currentScale() { return strip.scale; },
get isInPresentationMode() { return false; },
scrollPageIntoView({ pageNumber }) {
// pageNumber is a SOURCE page number; map it through our order so a link
// still lands on the right page after a reorder.
const uid = model.order.find((u) => model.page(u).src === pageNumber - 1);
if (uid && !model.page(uid).deleted) strip.scrollTo(uid);
},
get pageViewsReady() { return true; },
getPageView(i) { const uid = model.visible()[i]; return uid ? strip.views.get(uid)?.pv : null; },
};
}
async function teardown() {
// Tools first: it is the one holding listeners on the container, and a stale
// one would go on handling clicks against a strip that no longer exists.
try { session.tools?.destroy(); } catch {}
try { session.strip?.destroy(); } catch {}
try { session.rail?.destroy(); } catch {}
try { await session.pdf?.destroy(); } catch {}
session.pdf = null; session.model = null; session.strip = null;
session.rail = null; session.tools = null;
$("viewer").textContent = "";
}
// ---- open paths -------------------------------------------------------
// A tab that can be given files three ways — the dock, a button, a drop — has
// to decide what a handful of files MEANS before it can act. One PDF is a
// document to edit. Several are a document to build. Pictures are pages.
const isPdfFile = (f) => /\.pdf$/i.test(f.name) || f.type === "application/pdf";
const isImageFile = (f) => /^image\//.test(f.type) || /\.(png|jpe?g|webp|gif|bmp|avif|tiff?)$/i.test(f.name);
async function openFile(file) {
if (!file) return;
if (!isPdfFile(file)) { toast(`${file.name} is not a PDF`, true); return; }
if (!(await confirmDiscard())) return;
const buf = new Uint8Array(await file.arrayBuffer());
if (buf.subarray(0, 5).every((b, i) => b === "%PDF-".charCodeAt(i)) === false) {
toast("That file does not start with a PDF header", true);
return;
}
await loadBytes(buf, file.name);
}
async function openFiles(list) {
const files = [...(list || [])];
if (!files.length) return;
if (files.length === 1 && isPdfFile(files[0])) return openFile(files[0]);
if (files.every(isImageFile)) return fromImages(files);
if (files.every(isPdfFile)) return joinPdfs(files);
const odd = files.find((f) => !isPdfFile(f) && !isImageFile(f));
toast(odd ? `${odd.name} is neither a PDF nor a picture` : "Drop PDFs or pictures, not both at once", true);
}
/**
* Pictures in, one page each.
*
* The result is a document that exists only here until it is saved, so it is
* marked unsaved the moment it opens — otherwise closing the tab would throw
* away work that never touched a disk, without asking.
*/
async function fromImages(files) {
if (!(await confirmDiscard())) return;
$("images-count").textContent =
`${files.length} picture${files.length === 1 ? "" : "s"}, one per page, in the order your file browser listed them.`;
syncImagesDialog();
if (!(await openModal("modal-images"))) return;
const page = $("images-page").value;
const margin = Number($("images-margin").value) || 0;
setBusy(true, "Building…");
let out;
try {
out = await imagesToPdf(files, {
page, margin,
onProgress: (i, n) => setBusy(true, `Adding picture ${i} of ${n}…`),
});
} catch (e) {
setBusy(false);
status(`Could not build a PDF: ${e?.message || e}`, true);
toast(`Could not build a PDF: ${e?.message || e}`, true);
return;
}
if (!(await loadBytes(new Uint8Array(out.bytes), "pictures.pdf"))) return;
afterBuild(`${out.pages} page${out.pages === 1 ? "" : "s"} from your pictures`, out.failed);
}
/**
* Several PDFs, end to end. Order is the order they were handed over; the
* page rail is where it gets fixed, which is the honest answer to "can I
* reorder them" — yes, once they are one document.
*/
async function joinPdfs(files) {
if (!(await confirmDiscard())) return;
setBusy(true, "Joining…");
let out;
try {
out = await mergePdfs(files, { onProgress: (i, n) => setBusy(true, `Reading file ${i} of ${n}…`) });
} catch (e) {
setBusy(false);
status(`Could not join those files: ${e?.message || e}`, true);
toast(`Could not join those files: ${e?.message || e}`, true);
return;
}
if (!(await loadBytes(new Uint8Array(out.bytes), "joined.pdf"))) return;
const from = out.parts.map((p) => `${p.name} (${p.pages})`).join(", ");
afterBuild(`${out.pages} pages from ${out.parts.length} files · ${from}`, out.failed);
if (out.hadForms) {
toast("Form fields do not survive joining — the pages kept their look, not their fields", true);
}
}
// Shared tail for the two build paths: say where the document came from, say
// that it is not on disk yet, and name anything that had to be skipped.
function afterBuild(what, failed) {
session.model.markUnsaved();
paintChrome();
status(`${what} · not saved yet — use Save a copy`);
if (failed && failed.length) {
toast(`Skipped ${failed.length} file${failed.length === 1 ? "" : "s"}: ${failed.map((f) => f.name).join(", ")}`, true);
}
}
// A page shaped like its picture has no border to set, so the field goes away
// rather than sitting there doing nothing.
function syncImagesDialog() {
const fit = $("images-page").value === "fit";
$("images-margin-field").hidden = fit;
$("images-note").textContent = fit
? "Each page takes the shape of its picture, edge to edge, sized so the long side matches A4."
: "Each picture is centred on the page, scaled to fit inside the border. Wide pictures turn the page on its side.";
}
$("images-page").addEventListener("change", syncImagesDialog);
$("images-go").addEventListener("click", () => closeModal(true));
$("images-cancel").addEventListener("click", () => closeModal(null));
$("file-input").addEventListener("change", (e) => {
const f = e.target.files?.[0];
e.target.value = "";
openFile(f);
});
for (const [inputId, handler] of [["img-input", fromImages], ["merge-input", joinPdfs]]) {
$(inputId).addEventListener("change", (e) => {
const files = [...(e.target.files || [])];
e.target.value = "";
if (files.length) handler(files);
});
}
const pick = () => $("file-input").click();
$("open").addEventListener("click", pick);
$("start-open").addEventListener("click", pick);
$("start-images").addEventListener("click", () => $("img-input").click());
$("start-merge").addEventListener("click", () => $("merge-input").click());
// Drag and drop anywhere in the tab.
for (const type of ["dragenter", "dragover"]) {
window.addEventListener(type, (e) => {
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
document.body.dataset.dragover = "1";
});
}
window.addEventListener("dragleave", (e) => {
if (e.relatedTarget) return; // still inside the window
document.body.dataset.dragover = "";
});
window.addEventListener("drop", (e) => {
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
e.preventDefault();
document.body.dataset.dragover = "";
openFiles(e.dataTransfer.files);
});
// ---- page operations --------------------------------------------------
async function onRotate(uid) {
session.model.rotatePage(uid, 90);
session.strip.layout();
await session.strip.redraw(uid);
session.rail.refresh(uid);
paintChrome();
}
function onToggleDelete(uid) {
const was = session.model.page(uid).deleted;
const ok = session.model.setDeleted(uid, !was);
if (!ok && !was) { toast("A PDF needs at least one page", true); return; }
session.strip.layout();
session.rail.build();
paintChrome();
paintPageInfo();
}
function onReorder(uid, beforeUid) {
session.model.reorder(uid, beforeUid);
session.strip.layout();
session.rail.build();
paintChrome();
paintPageInfo();
}
$("restore-pages").addEventListener("click", () => {
session.model.restoreAllPages();
session.strip.layout();
session.rail.build();
paintChrome();
});
// ---- toolbar ----------------------------------------------------------
let redactAcknowledged = false;
let flattenRedactions = true;
/**
* Arm a tool and sync the toolbar. Tools calls this itself after placing a
* stamp, so the button highlight and the armed tool cannot drift apart.
*/
function selectTool(t) {
for (const o of document.querySelectorAll(".tool")) o.classList.toggle("active", o.dataset.tool === t);
session.tools?.setTool(t);
status(TOOL_HINTS[t] || "");
}
for (const b of document.querySelectorAll(".tool[data-tool]")) {
b.addEventListener("click", async () => {
const t = b.dataset.tool;
// Which signature is settled when the tool is picked up, so placing it
// is one click per place — and so there is somewhere to add, rename or
// redraw one, which a tool that silently reused the last had nowhere for.
if (t === "signature") {
selectTool(t);
const sig = await chooseSignature();
if (!sig) { selectTool("select"); return; }
status(`Click to place "${sig.name || "your signature"}".`);
return;
}
if (t === "redact" && !redactAcknowledged) {
const ok = await openModal("modal-redact");
if (!ok) return;
redactAcknowledged = true;
flattenRedactions = $("redact-flatten").checked;
try { await sm?.storage?.set("redactAcknowledged", true); } catch {}
}
selectTool(t);
});
}
$("redact-ok").addEventListener("click", () => closeModal(true));
$("redact-cancel").addEventListener("click", () => closeModal(null));
const TOOL_HINTS = {
select: "Click a mark, or drag an area around several. Shift-click adds one, Ctrl+A takes the page.",
highlight: "Drag across text to highlight it.",
underline: "Drag across text to underline it.",
strikeout: "Drag across text to strike it through.",
pen: "Drag to draw.",
rect: "Drag to draw a rectangle.",
ellipse: "Drag to draw an ellipse.",
line: "Drag to draw a line.",
arrow: "Drag from tail to head.",
text: "Click where the text should start.",
edittext: "Click a line of the document's own text to replace it.",
signature: "Click to place your signature.",
redact: "Drag a box over what has to go. Redacted pages are rebuilt as images on save.",
};
for (const s of document.querySelectorAll(".swatch")) {
s.addEventListener("click", () => {
for (const o of document.querySelectorAll(".swatch")) o.classList.toggle("active", o === s);
session.tools?.setColor(s.dataset.color);
});
}
const fillBtn = $("toggle-fill");
if (fillBtn) fillBtn.addEventListener("click", () => {
const on = !fillBtn.classList.contains("active");
fillBtn.classList.toggle("active", on);
session.tools?.setFill(on);
});
for (const w of document.querySelectorAll(".width")) {
w.addEventListener("click", () => {
for (const o of document.querySelectorAll(".width")) o.classList.toggle("active", o === w);
session.tools?.setWidth(parseFloat(w.dataset.width));
});
}
$("undo").addEventListener("click", () => { session.model?.undo(); afterHistory(); });
$("redo").addEventListener("click", () => { session.model?.redo(); afterHistory(); });
function afterHistory() {
if (!session.model) return;
session.strip.layout();
session.rail.build();
// Through select() rather than by assignment, so the selection bar hears
// about it and does not hang over a mark that undo just removed.
session.tools.select(null);
paintChrome();
paintPageInfo();
}
$("zoom").addEventListener("change", (e) => {
const v = e.target.value;
session.strip?.setScaleMode(/^[\d.]+$/.test(v) ? parseFloat(v) : v);
paintPageInfo();
positionSelectionBar();
});
$("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); });
$("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); });
function syncZoomSelect() {
positionSelectionBar();
if (!session.strip) return;
const exact = String(session.strip.scale);
const opt = [...$("zoom").options].find((o) => o.value === exact);
$("zoom").value = opt ? exact : $("zoom").value;
paintPageInfo();
}
$("toggle-rail").addEventListener("click", () => {
const off = document.body.dataset.rail === "off";
document.body.dataset.rail = off ? "" : "off";
if (off) session.rail?.build();
session.strip?.computeScale();
session.strip?.layout();
paintPageInfo();
});
let resizeT = null;
window.addEventListener("resize", () => {
clearTimeout(resizeT);
resizeT = setTimeout(() => {
if (!session.strip) return;
if (typeof session.strip.scaleMode === "string") { session.strip.computeScale(); session.strip.layout(); }
paintPageInfo();
positionSelectionBar();
}, 140);
});
// ---- replacing the document's own text --------------------------------
//
// The run's size and position come from pdf.js's text layer; its colours come
// from the rendered page, because nothing in the text API reports them. Both
// are guesses made from what is actually on screen, which is the same thing
// the reader is looking at, so a wrong guess is visible immediately rather
// than only after saving.
function hex([r, g, b]) {
return "#" + [r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0")).join("");
}
/**
* The background and ink colours of one text run, read off the rendered page.
* Background is the most common colour in the run's box — type is a minority
* of the pixels even when it is dense. Ink is whatever sits furthest from it.
*/
function sampleRun(run) {
const fallback = { bg: "#ffffff", ink: "#000000", sampled: false };
try {
const view = session.strip?.views.get(run.uid);
const canvas = view?.pv?.div?.querySelector(".canvasWrapper canvas");
if (!canvas || !canvas.width) return fallback;
const cr = canvas.getBoundingClientRect();
if (!cr.width || !cr.height) return fallback;
const sx = canvas.width / cr.width, sy = canvas.height / cr.height;
const r = run.clientRect;
const x0 = Math.max(0, Math.floor((r.left - cr.left) * sx));
const y0 = Math.max(0, Math.floor((r.top - cr.top) * sy));
const w = Math.min(canvas.width - x0, Math.ceil(r.width * sx));
const h = Math.min(canvas.height - y0, Math.ceil(r.height * sy));
if (w < 2 || h < 2) return fallback;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const data = ctx.getImageData(x0, y0, w, h).data;
// Buckets of 8 per channel: exact equality would split an anti-aliased
// background into hundreds of near-identical shades and no single one
// would win. The bucket only decides WHICH pixels are the background —
// the colour itself is their average, because reconstructing it from the
// bucket index rounds white down to #f8f8f8, and a not-quite-white patch
// over a white page is a visible seam.
const buckets = new Map();
for (let i = 0; i < data.length; i += 4) {
const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3);
let acc = buckets.get(key);
if (!acc) buckets.set(key, (acc = { n: 0, r: 0, g: 0, b: 0 }));
acc.n++; acc.r += data[i]; acc.g += data[i + 1]; acc.b += data[i + 2];
}
let win = null;
for (const acc of buckets.values()) if (!win || acc.n > win.n) win = acc;
if (!win) return fallback;
const bg = [win.r / win.n, win.g / win.n, win.b / win.n];
let ink = [0, 0, 0], far = -1;
for (let i = 0; i < data.length; i += 4) {
const d = Math.abs(data[i] - bg[0]) + Math.abs(data[i + 1] - bg[1]) + Math.abs(data[i + 2] - bg[2]);
if (d > far) { far = d; ink = [data[i], data[i + 1], data[i + 2]]; }
}
// Nothing that reads as ink — a blank strip, or type too faint to trust.
if (far < 40) return { ...fallback, bg: hex(bg), sampled: true };
return { bg: hex(bg), ink: hex(ink), sampled: true };
} catch { return fallback; }
}
async function onEditRun(run) {
const look = sampleRun(run);
$("modal-text-title").textContent = "Replace text";
$("text-body").value = run.text;
$("text-size-field").hidden = true; // the size comes from the document
$("text-bold").classList.remove("active");
$("text-italic").classList.remove("active");
$("text-warn").hidden = true;
const note = $("text-note");
note.hidden = false;
note.textContent = `${run.size.toFixed(1)} pt from the document. The replacement is drawn over the original, `
+ `which stays in the file underneath — use Redact if it has to be gone.`;
const ok = await openModal("modal-text");
$("text-size-field").hidden = false;
note.hidden = true;
if (!ok) return null;
const a = session.tools.replaceRun(run, {
text: $("text-body").value,
size: run.size,
color: look.ink,
cover: look.bg,
bold: $("text-bold").classList.contains("active"),
italic: $("text-italic").classList.contains("active"),
});
if (a && !look.sampled) toast("Could not read the page's colours; used black on white.", true);
return a;
}
// ---- selection bar ----------------------------------------------------
//
// A floating strip pinned to whatever is selected, carrying the actions that
// only make sense for that mark: edit and type controls for text, fill for a
// shape, and duplicate/delete for anything. Position is fixed rather than
// parented into the page, so it is never clipped by a page edge and never has
// to be re-homed when the selection moves to another page.
//
// It exists because Delete-on-the-keyboard was the only way to remove a mark,
// which is undiscoverable: nothing on screen said a selected mark could go.
const selbar = $("selbar");
function paintSelectionBar(id) {
if (inline?.isOpen) return; // the caret owns the bar while it is up
const a = id && session.model ? session.model.annot(id) : null;
if (!a) { selbar.hidden = true; return; }
delete selbar.dataset.mode;
$("sb-dup").hidden = false;
$("sb-del").hidden = false;
// Several at once: offer only what is true of every one of them. A bar
// showing a size for a group that is half shapes would be lying about what
// pressing it does.
const picked = session.tools?.selectedAnnots?.() || [a];
if (picked.length > 1) {
const isText = (m) => m.kind === "text" || m.kind === "textedit";
const allText = picked.every(isText);
const allShapes = picked.every((m) => m.kind === "rect" || m.kind === "ellipse");
$("sb-edit").hidden = true; // one caret, one mark
$("sb-textsize").hidden = !allText;
$("sb-bold").hidden = !allText;
$("sb-italic").hidden = !allText;
$("sb-fill").hidden = !allShapes;
if (allText) {
const sizes = new Set(picked.map((m) => Math.round(m.size || 12)));
$("sb-size").textContent = sizes.size === 1 ? String([...sizes][0]) : "—";
$("sb-bold").classList.toggle("active", picked.every((m) => m.bold));
$("sb-italic").classList.toggle("active", picked.every((m) => m.italic));
}
if (allShapes) $("sb-fill").classList.toggle("active", picked.every((m) => m.fill));
selbar.dataset.mode = "group";
selbar.hidden = false;
positionSelectionBar();
status(`${picked.length} marks selected — drag to move them, Del to remove them`);
return;
}
// A replaced run is text too. Leaving it out meant the one mark made of
// words offered no way to change them — the exact gap this bar exists for.
const isText = a.kind === "text" || a.kind === "textedit";
const isShape = a.kind === "rect" || a.kind === "ellipse";
$("sb-edit").hidden = !isText;
$("sb-textsize").hidden = !isText;
$("sb-bold").hidden = !isText;
$("sb-italic").hidden = !isText;
$("sb-fill").hidden = !isShape;
if (isText) {
$("sb-size").textContent = String(Math.round(a.size || 12));
$("sb-bold").classList.toggle("active", !!a.bold);
$("sb-italic").classList.toggle("active", !!a.italic);
}
if (isShape) $("sb-fill").classList.toggle("active", !!a.fill);
selbar.hidden = false;
positionSelectionBar();
}
function positionSelectionBar() {
const t = session.tools, m = session.model, strip = session.strip;
const a = t && m ? m.annot(t.selected) : null;
if (!a || selbar.hidden) return;
const ov = strip?.overlayFor(a.page);
const view = strip?.views.get(a.page);
if (!ov?.viewport || !view) { selbar.hidden = true; return; }
// Over the whole group when there is one, so the bar does not cling to
// whichever mark happens to be the anchor.
const picked = (t.selectedAnnots?.() || [a]).filter((x) => x.page === a.page);
const b = picked.length > 1 ? unionOf(picked) : boundsOf(a);
if (!b) { selbar.hidden = true; return; }
const [ax, ay] = ov.toView(b.x, b.y + b.h);
const [bx, by] = ov.toView(b.x + b.w, b.y);
const pr = view.pv.div.getBoundingClientRect();
const left = pr.left + Math.min(ax, bx);
const right = pr.left + Math.max(ax, bx);
const top = pr.top + Math.min(ay, by);
const bottom = pr.top + Math.max(ay, by);
const box = selbar.getBoundingClientRect();
const cr = $("viewerContainer").getBoundingClientRect();
const GAP = 10;
// Above the mark by preference; below when there is no room up there.
let y = top - box.height - GAP;
if (y < cr.top + 4) y = Math.min(bottom + GAP, cr.bottom - box.height - 4);
let x = (left + right) / 2 - box.width / 2;
x = Math.min(Math.max(x, cr.left + 4), cr.right - box.width - 4);
selbar.style.left = `${Math.round(x)}px`;
selbar.style.top = `${Math.round(y)}px`;
// A selection scrolled out of the viewport takes its bar with it.
selbar.classList.toggle("offscreen", bottom < cr.top || top > cr.bottom);
}
// The bar would jitter under the cursor mid-gesture, so it steps aside while
// a drag is in flight and comes back where the mark ended up.
$("viewerContainer").addEventListener("pointerdown", () => { document.body.dataset.dragging = "1"; });
window.addEventListener("pointerup", () => {
document.body.dataset.dragging = "";
positionSelectionBar();
});
$("viewerContainer").addEventListener("scroll", () => {
// The caret is positioned inside the page, so scrolling carries it along;
// the bar floats over the viewport and has to be told.
if (inline?.isOpen) positionInlineBar(); else positionSelectionBar();
}, { passive: true });
$("sb-edit").addEventListener("click", () => {
const id = session.tools?.selected;
if (!id) return;
const a = session.model?.annot(id);
if (a?.kind === "text") beginText({ id }); else onTextModal(id, null);
});
// Pressing a style button must not take the caret away: focus leaving the
// editor is how a click elsewhere finishes the edit, and these are not
// elsewhere.
for (const b of selbar.querySelectorAll("button")) {
b.addEventListener("pointerdown", (e) => { if (inline?.isOpen) e.preventDefault(); });
}
$("sb-del").addEventListener("click", () => { session.tools?.deleteSelected(); paintChrome(); });
$("sb-dup").addEventListener("click", () => { session.tools?.duplicateSelected(); paintChrome(); });
$("sb-bold").addEventListener("click", () => toggleTextStyle("bold"));
$("sb-italic").addEventListener("click", () => toggleTextStyle("italic"));
$("sb-fill").addEventListener("click", () => {
const a = session.model?.annot(session.tools?.selected);
if (a) session.tools.setFill(!a.fill);
});
$("sb-smaller").addEventListener("click", () => stepTextSize(-1));
$("sb-bigger").addEventListener("click", () => stepTextSize(1));
const SIZE_STEPS = [6, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96];
function toggleTextStyle(which) {
// While a caret is open the bar is driving the caret, not a mark.
if (inline?.isOpen) {
inline.setStyle({ [which]: !inline.state[which] });
paintInlineBar();
return;
}
const a = session.model?.annot(session.tools?.selected);
if (!a || (a.kind !== "text" && a.kind !== "textedit")) return;
delete a._w; // the glyphs change width; re-measure
session.tools.patchSelected({ [which]: !a[which] });
}
function stepTextSize(dir) {
const nextSize = (at) => dir > 0
? SIZE_STEPS.find((v) => v > at + 0.01)
: [...SIZE_STEPS].reverse().find((v) => v < at - 0.01);
if (inline?.isOpen) {
const next = nextSize(inline.state.size);
if (next == null) return;
inline.setStyle({ size: clampFont(next) });
paintInlineBar();
return;
}
const a = session.model?.annot(session.tools?.selected);
if (!a || (a.kind !== "text" && a.kind !== "textedit")) return;
const next = nextSize(a.size || 12);
if (next == null) return;
delete a._w;
session.tools.patchSelected({ size: clampFont(next) });
}
// ---- text stamp -------------------------------------------------------
/**
* One dialog for both jobs. `id` set means rewrite that mark in place, which
* is what a double-click on placed text opens; `at` set means place a new one.
*/
// ---- typing on the page -----------------------------------------------
let inline = null;
function inlineEditor() {
if (!inline) {
inline = new InlineText({
strip: session.strip,
onCommit: (s) => finishInlineText(s),
onCancel: (s) => {
// A mark being re-edited was hidden, not removed; put it back as it
// was. A brand-new one was never added, so there is nothing to undo.
const a = s.id ? session.model?.annot(s.id) : null;
if (a) { delete a._editing; session.strip?.refreshOverlay(s.uid, s.id); }
paintSelectionBar(session.tools?.selected || null);
status("");
},
onLayout: () => positionInlineBar(),
});
}
inline.strip = session.strip; // a new document brings a new strip
return inline;
}
/**
* Start typing at a point on the page, or inside an existing stamp.
* @param {{uid?:string, x?:number, y?:number, id?:string}} where
*/
function beginText(where) {
if (!session.model) return;
const ed = inlineEditor();
const existing = where.id ? session.model.annot(where.id) : null;
if (where.id && !existing) return;
const seed = existing
? { uid: existing.page, x: existing.x, y: existing.y, id: existing.id,
text: existing.text, size: existing.size || 12,
bold: !!existing.bold, italic: !!existing.italic, color: existing.color }
: { uid: where.uid, x: where.x, y: where.y, text: "",
size: lastTextSize, bold: lastTextBold, italic: lastTextItalic,
color: session.tools.ink };
// While it is being typed, the stamp itself is hidden: seeing the old words
// underneath the new ones reads as a rendering fault.
if (existing) { existing._editing = true; session.strip.refreshOverlay(existing.page, existing.id); }
if (!ed.start(seed)) { if (existing) delete existing._editing; return; }
paintInlineBar();
status(existing ? "Editing text — Escape to leave it as it was." : "Type. Click elsewhere when you are done.");
}
function finishInlineText(s) {
const model = session.model;
if (!model) return;
lastTextSize = s.size; lastTextBold = s.bold; lastTextItalic = s.italic;
const existing = s.id ? model.annot(s.id) : null;
if (existing) delete existing._editing;
const text = String(s.text || "");
if (existing) {
// updateText deletes the mark when the text is emptied, which is the
// right reading of "select it all and press delete".
session.tools.updateText(s.id, { text, size: s.size, bold: s.bold, italic: s.italic });
} else if (text.trim()) {
session.tools.placeText({ uid: s.uid, x: s.x, y: s.y },
{ text, size: s.size, bold: s.bold, italic: s.italic });
} else {
// Typed nothing. Nothing was ever added, so nothing has to be taken away.
session.strip?.refreshOverlay(s.uid, session.tools?.selected || null);
}
paintSelectionBar(session.tools?.selected || null);
status("");
}
/** Select every mark on the page the user is looking at. */
function selectAllOnPage() {
const t = session.tools, m = session.model, strip = session.strip;
if (!t || !m || !strip) return;
const uid = strip.current || m.visible()[0];
if (!uid) return;
const ids = m.annotsFor(uid).map((a) => a.id);
if (!ids.length) { toast("Nothing on this page to select"); return; }
selectTool("select");
t.selectMany(ids);
toast(`${ids.length} mark${ids.length === 1 ? "" : "s"} selected`);
}
/** The style buttons, while a caret is open rather than a mark selected. */
function paintInlineBar() {
const st = inline?.state;
if (!st) { selbar.hidden = true; return; }
selbar.dataset.mode = "inline";
$("sb-edit").hidden = true;
$("sb-textsize").hidden = false;
$("sb-bold").hidden = false;
$("sb-italic").hidden = false;
$("sb-fill").hidden = true;
$("sb-dup").hidden = true;
$("sb-del").hidden = true;
$("sb-size").textContent = String(Math.round(st.size));
$("sb-bold").classList.toggle("active", !!st.bold);
$("sb-italic").classList.toggle("active", !!st.italic);
selbar.hidden = false;
positionInlineBar();
}
function positionInlineBar() {
const r = inline?.rect();
if (!r || selbar.hidden) return;
const box = selbar.getBoundingClientRect();
const cr = $("viewerContainer").getBoundingClientRect();
const GAP = 10;
let top = r.top - box.height - GAP;
if (top < cr.top + 4) top = r.bottom + GAP;
const left = Math.min(Math.max(r.left, cr.left + 4), cr.right - box.width - 4);
selbar.style.left = `${Math.round(left)}px`;
selbar.style.top = `${Math.round(top)}px`;
}
let lastTextSize = 12, lastTextBold = false, lastTextItalic = false;
async function onTextModal(id, at) {
const existing = id ? session.model?.annot(id) : null;
$("modal-text-title").textContent = existing ? "Edit text" : "Text stamp";
$("text-body").value = existing ? existing.text : "";
// A run lifted out of a document is whatever size the document set — 11 pt,
// 9.5 pt — and the picker only lists round numbers. Without this the select
// falls back to empty and the size silently becomes 12 on the way out.
const wanted = existing ? existing.size || 12 : 12;
const sizeSel = $("text-size");
if (![...sizeSel.options].some((o) => Number(o.value) === Number(wanted))) {
const opt = document.createElement("option");
opt.value = String(wanted);
opt.textContent = String(Math.round(wanted * 10) / 10);
opt.dataset.adhoc = "1";
sizeSel.append(opt);
}
sizeSel.value = String(wanted);
$("text-bold").classList.toggle("active", !!existing?.bold);
$("text-italic").classList.toggle("active", !!existing?.italic);
$("text-warn").hidden = true;
const p = openModal("modal-text");
setTimeout(() => { $("text-body").focus(); $("text-body").select(); }, 30);
const ok = await p;
const props = {
text: $("text-body").value,
size: parseFloat($("text-size").value) || wanted,
bold: $("text-bold").classList.contains("active"),
italic: $("text-italic").classList.contains("active"),
};
for (const o of [...$("text-size").options]) if (o.dataset.adhoc) o.remove();
if (!ok) return null;
return existing ? session.tools.updateText(id, props) : session.tools.placeText(at, props);
}
for (const b of [$("text-bold"), $("text-italic")]) {
b?.addEventListener("click", () => b.classList.toggle("active"));
}
$("text-ok").addEventListener("click", () => closeModal(true));
$("text-cancel").addEventListener("click", () => closeModal(null));
$("text-body").addEventListener("input", () => {
const n = unencodableStampsIn($("text-body").value);
const w = $("text-warn");
w.hidden = n === 0;
w.classList.add("warn");
if (n) w.textContent = `${n} character${n === 1 ? "" : "s"} cannot be written with the built-in font and will be saved as "?".`;
});
function unencodableStampsIn(text) {
// Same WinAnsi test the writer uses, applied live so the user finds out
// before placing the stamp rather than after saving.
let n = 0;
for (const ch of String(text)) {
const code = ch.codePointAt(0);
const ok = (code >= 0x20 && code <= 0x7e) || (code >= 0xa0 && code <= 0xff) ||
[0x2018, 0x2019, 0x201c, 0x201d, 0x2013, 0x2014, 0x2022, 0x20ac, 0x2026, 0x2122].includes(code) ||
ch === "\n";
if (!ok) n++;
}
return n;
}
// ---- signatures -------------------------------------------------------
// A library, not a single slot. One signature was enough until you needed
// initials as well as a full name, or your own next to someone else's — at
// which point the only way to change the one you had was to have no way at
// all, because every click placed it again.
let signatures = []; // [{ id, name, strokes, aspect }]
let savedSignature = null; // the one the armed tool will place
let pad = null;
async function persistSignatures() {
try { await sm?.storage?.set("signatures", signatures); } catch {}
}
function sigThumb(sig) {
// The strokes are normalised 0..1 with y downwards, which is already an SVG
// viewBox; nothing has to be scaled to draw a preview.
const paths = (sig.strokes || []).map((s) =>
`<path d="${s.map(([x, y], i) => `${i ? "L" : "M"}${(x * 100).toFixed(1)} ${(y * 100 * (sig.aspect || 0.34)).toFixed(1)}`).join(" ")}"/>`
).join("");
return `<svg viewBox="0 0 100 ${(100 * (sig.aspect || 0.34)).toFixed(1)}" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
preserveAspectRatio="xMidYMid meet">${paths}</svg>`;
}
function paintSignatureList() {
const list = $("sign-list");
list.textContent = "";
$("sign-empty").hidden = signatures.length > 0;
for (const sig of signatures) {
const li = document.createElement("li");
li.className = "sigrow";
li.innerHTML = `<span class="sigpreview">${sigThumb(sig)}</span>
<span class="signame"></span>
<span class="sigacts">
<button class="btn primary" data-act="use">Use</button>
<button class="btn" data-act="edit">Redraw</button>
<button class="btn" data-act="rename">Rename</button>
<button class="btn" data-act="delete">Delete</button>
</span>`;
li.querySelector(".signame").textContent = sig.name || "Signature";
li.querySelector('[data-act="use"]').addEventListener("click", () => closeModal(sig));
li.querySelector('[data-act="edit"]').addEventListener("click", () => showSignaturePad(sig));
li.querySelector('[data-act="rename"]').addEventListener("click", async () => {
const name = window.prompt("Name this signature", sig.name || "Signature");
if (name === null) return;
sig.name = name.trim().slice(0, 60) || "Signature";
await persistSignatures();
paintSignatureList();
});
li.querySelector('[data-act="delete"]').addEventListener("click", async () => {
if (!window.confirm(`Delete "${sig.name || "Signature"}"?`)) return;
signatures = signatures.filter((s) => s.id !== sig.id);
if (savedSignature?.id === sig.id) savedSignature = null;
await persistSignatures();
paintSignatureList();
});
list.append(li);
}
}
/** Swap the modal over to the pad, either blank or loaded with one to redraw. */
function showSignaturePad(existing = null) {
if (!pad) {
pad = new SignaturePad($("sign-pad"));
pad.onChange((has) => { $("sign-ok").disabled = !has; });
}
pad.clear();
padEditing = existing;
if (existing) pad.load(existing.strokes, existing.aspect);
$("sign-title").textContent = existing ? "Redraw this signature" : "Draw a signature";
$("sign-name").value = existing?.name || "";
$("sign-library").hidden = true;
$("sign-draw").hidden = false;
$("sign-ok").disabled = pad.isEmpty;
}
function showSignatureLibrary() {
padEditing = null;
$("sign-title").textContent = "Your signatures";
$("sign-draw").hidden = true;
$("sign-library").hidden = false;
paintSignatureList();
}
let padEditing = null;
/**
* Open the library and hand back whichever signature was chosen, or null.
* With nothing saved there is nothing to choose from, so it opens on the pad.
*/
async function chooseSignature() {
if (signatures.length) showSignatureLibrary(); else showSignaturePad(null);
const chosen = await openModal("modal-sign");
if (!chosen) return null;
savedSignature = chosen;
return chosen;
}
$("sign-new").addEventListener("click", () => showSignaturePad(null));
$("sign-close").addEventListener("click", () => closeModal(null));
$("sign-cancel").addEventListener("click", () => {
// "Back" when there is a library to go back to; otherwise it is the only
// screen, so it closes.
if (signatures.length) showSignatureLibrary(); else closeModal(null);
});
$("sign-clear").addEventListener("click", () => pad?.clear());
$("sign-ok").addEventListener("click", async () => {
const res = pad?.result();
if (!res) return;
const name = $("sign-name").value.trim().slice(0, 60) ||
padEditing?.name || `Signature ${signatures.length + 1}`;
let sig;
if (padEditing) {
sig = Object.assign(padEditing, { ...res, name });
} else {
sig = { id: `sig-${Date.now().toString(36)}`, name, ...res };
signatures.push(sig);
}
await persistSignatures();
closeModal(sig);
});
// ---- save -------------------------------------------------------------
function defaultSaveName() {
const base = String(session.name || "document.pdf").replace(/\.pdf$/i, "");
return `${base}-edited.pdf`;
}
/**
* Everything the user typed into a form field, keyed by field NAME.
*
* Driven from the document's field list rather than from the storage's
* contents. pdf.js keys its annotation storage by annotation id ("11R"), which
* means nothing to pdf-lib — pdf-lib addresses fields by name — so the field
* list is needed for the mapping either way, and iterating it also keeps
* pdf.js's own editor bookkeeping out of the results for free.
*/
async function harvestFormValues(pdf) {
const out = [];
let fields = null;
try { fields = await pdf.getFieldObjects(); } catch { return out; }
if (!fields) return out;
const storage = pdf.annotationStorage;
for (const [name, objs] of fields) {
for (const o of objs || []) {
// `has` distinguishes a field the user touched from one they left alone;
// writing back every field would rewrite appearances across the whole
// document for no reason.
if (!o?.id || !storage.has(o.id)) continue;
const entry = storage.getRawValue(o.id);
if (!entry || typeof entry !== "object" || !("value" in entry)) continue;
out.push({ name, value: entry.value });
break; // a field's kids (radio buttons) share one value
}
}
return out;
}
/** Render one page to a PNG at FLATTEN_DPI, unrotated, for the flatten pass. */
async function rasterizePage(uid) {
const entry = session.strip.views.get(uid);
const scale = FLATTEN_DPI / 72;
// rotation 0, so the image lands in the page's own unrotated coordinate
// space — the page's /Rotate then presents it exactly as before.
const vp = entry.pdfPage.getViewport({ scale, rotation: 0 });
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(vp.width));
canvas.height = Math.max(1, Math.ceil(vp.height));
const ctx = canvas.getContext("2d", { alpha: false });
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await entry.pdfPage.render({ canvasContext: ctx, viewport: vp, intent: "print" }).promise;
const blob = await new Promise((res, rej) =>
canvas.toBlob((b) => b ? res(b) : rej(new Error("toBlob returned null")), "image/png"));
return new Uint8Array(await blob.arrayBuffer());
}
async function doSave() {
const m = session.model;
if (!m) return;
$("save-name").value = defaultSaveName();
const summary = $("save-summary");
summary.textContent = "";
const lines = [];
if (m.annots.length) lines.push([`${m.annots.length} mark${m.annots.length === 1 ? "" : "s"} written into the file`, false]);
const formValues = await harvestFormValues(session.pdf);
if (formValues.length) lines.push([`${formValues.length} form field${formValues.length === 1 ? "" : "s"} filled in`, false]);
if (m.deletedCount()) lines.push([`${m.deletedCount()} page${m.deletedCount() === 1 ? "" : "s"} removed`, false]);
if (m.pagesChanged() && !m.deletedCount()) lines.push(["pages reordered", false]);
if (m.rotationsChanged()) lines.push(["page rotation changed", false]);
const red = m.redactedPages();
if (red.size) {
lines.push([flattenRedactions
? `${red.size} redacted page${red.size === 1 ? "" : "s"} rebuilt as an image, so the text under the boxes is gone`
: `${red.size} redacted page${red.size === 1 ? "" : "s"} — the text under the boxes stays extractable`,
!flattenRedactions]);
}
const replaced = replacedRuns(m);
if (replaced) lines.push([`${replaced} text run${replaced === 1 ? "" : "s"} replaced in place — the original text stays in the file underneath`, false]);
const bad = unencodableStamps(m);
if (bad) lines.push([`${bad} text stamp${bad === 1 ? "" : "s"} contain characters the built-in font cannot write`, true]);
if (!lines.length) lines.push(["no changes — this saves a copy of the original", false]);
for (const [text, warn] of lines) {
const li = document.createElement("li");
li.textContent = text;
if (warn) li.className = "warn";
summary.append(li);
}
const go = await openModal("modal-save");
if (!go) return;
let filename = String($("save-name").value || "").trim() || defaultSaveName();
if (!/\.pdf$/i.test(filename)) filename += ".pdf";
setBusy(true, "Writing…");
try {
const { bytes, report } = await buildPdf({
originalBytes: session.bytes,
model: m,
formValues,
flatten: flattenRedactions,
rasterize: rasterizePage,
});
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = $("download-link");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 8000);
m.markSaved();
paintChrome();
const kb = (bytes.length / 1024).toFixed(0);
status(`Saved ${filename} · ${kb} KB${report.flattened ? ` · ${report.flattened} page(s) rebuilt as images` : ""}`);
toast(`Saved ${filename} (${kb} KB)`);
for (const w of report.warn) toast(w, true);
} catch (e) {
console.error("save failed:", e);
status(`Save failed: ${e?.message || e}`, true);
toast(`Save failed: ${e?.message || e}`, true);
} finally {
setBusy(false);
}
}
$("save").addEventListener("click", doSave);
$("save-go").addEventListener("click", () => closeModal(true));
$("save-cancel").addEventListener("click", () => closeModal(null));
// ---- convert to Word --------------------------------------------------
//
// Named a conversion, not an edit, because that is what it is. A PDF holds
// glyphs with coordinates; Word wants paragraphs. Everything between is
// inferred from geometry, so the layout is rebuilt rather than carried over,
// and the dialog says so before anything is written.
/** Text items and page geometry, in the order the pages currently read. */
async function gatherPages() {
const m = session.model, strip = session.strip;
const pages = [];
const visible = m.visible();
for (let i = 0; i < visible.length; i++) {
const uid = visible[i];
const view = strip.views.get(uid);
if (!view) continue;
if (!view.textContent) {
try { view.textContent = await view.pdfPage.getTextContent(); }
catch { view.textContent = { items: [], styles: {} }; }
}
const tc = view.textContent;
// pdf.js names fonts by an internal id. The real name — the only place a
// PDF records bold or italic — is on the font object when it has loaded,
// with the style block as a weaker fallback.
const fontMap = new Map();
for (const id of Object.keys(tc.styles || {})) {
let name = tc.styles[id]?.fontFamily || id;
try {
if (view.pdfPage.commonObjs.has(id)) name = view.pdfPage.commonObjs.get(id)?.name || name;
} catch {}
fontMap.set(id, name);
}
const info = m.size(uid);
pages.push({
index: i,
label: i + 1,
items: tc.items || [],
styles: tc.styles || {},
fontMap,
widthPt: info.width,
heightPt: info.height,
replacements: m.annotsFor(uid)
.filter((a) => a.kind === "textedit")
.map((a) => ({ x: a.x, y: a.y, w: a.w, h: a.h, text: a.text })),
});
}
return pages;
}
function defaultDocxName() {
const base = String(session.name || "document.pdf").replace(/\.pdf$/i, "");
return `${base}.docx`;
}
async function convertToWord() {
const m = session.model;
if (!m) return;
setBusy(true, "Reading the text…");
let result;
try {
result = reflow(await gatherPages());
} catch (e) {
setBusy(false);
console.error("convert failed:", e);
status(`Could not read this document's text: ${e?.message || e}`, true);
toast(`Could not read this document's text: ${e?.message || e}`, true);
return;
}
setBusy(false);
const { paragraphs, warnings, stats } = result;
$("convert-name").value = defaultDocxName();
const list = $("convert-summary");
list.textContent = "";
const add = (text, warn) => {
const li = document.createElement("li");
li.textContent = text;
if (warn) li.className = "warn";
list.append(li);
};
if (!stats.paragraphs) {
add("No text was found in this document at all. If it looks like pages of writing, "
+ "they are images of writing, and turning those into text needs character recognition, "
+ "which this editor does not do.", true);
} else {
add(`${stats.paragraphs} paragraph${stats.paragraphs === 1 ? "" : "s"} across `
+ `${stats.pages} page${stats.pages === 1 ? "" : "s"}, body text read as ${stats.bodySize} pt`);
if (stats.headings) add(`${stats.headings} of them look like headings and will carry Word's heading styles`);
}
for (const w of warnings) add(w, true);
add("Bold, italic, size and colour carry over. The layout is rebuilt, so line breaks and "
+ "page breaks will not land where they do in the PDF. Tables become plain paragraphs, "
+ "and images, forms and your annotations are not included.");
$("convert-go").disabled = !stats.paragraphs;
const go = await openModal("modal-convert");
$("convert-go").disabled = false;
if (!go) return;
let filename = String($("convert-name").value || "").trim() || defaultDocxName();
if (!/\.docx$/i.test(filename)) filename += ".docx";
setBusy(true, "Writing…");
try {
const first = (await gatherPages())[0];
const bytes = buildDocx({
paragraphs,
page: { widthPt: first?.widthPt || 612, heightPt: first?.heightPt || 792 },
defaults: { size: stats.bodySize || 11, family: "Calibri" },
});
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
const url = URL.createObjectURL(blob);
const a = $("download-link");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 8000);
const kb = (bytes.length / 1024).toFixed(0);
status(`Converted to ${filename} · ${kb} KB · ${stats.paragraphs} paragraphs`);
toast(`Converted to ${filename} (${kb} KB)`);
} catch (e) {
console.error("docx write failed:", e);
status(`Could not write the Word file: ${e?.message || e}`, true);
toast(`Could not write the Word file: ${e?.message || e}`, true);
} finally {
setBusy(false);
}
}
$("convert").addEventListener("click", convertToWord);
$("convert-go").addEventListener("click", () => closeModal(true));
$("convert-cancel").addEventListener("click", () => closeModal(null));
// ---- discard / close --------------------------------------------------
async function confirmDiscard() {
if (!session.model || !session.model.isDirty()) return true;
// Deliberately the browser's own confirm(): it is modal against the tab, it
// cannot be dismissed by a stray click the way the in-page scrim can, and
// losing an hour of markup to a misplaced click is not a risk worth a
// prettier dialog.
return window.confirm("This document has unsaved changes. Discard them?");
}
$("discard").addEventListener("click", async () => {
if (!(await confirmDiscard())) return;
if (session.scratchId && sm?.invoke) {
try { await sm.invoke("release", { id: session.scratchId }); } catch {}
}
try { sm?.closeTab?.(); } catch {}
});
// Belt and braces for a tab closed from the tab strip rather than our button.
window.addEventListener("beforeunload", (e) => {
if (session.model?.isDirty()) { e.preventDefault(); e.returnValue = ""; }
});
// A link inside a PDF must not navigate this tab — that would drop the whole
// editing session on the floor. Hand it to the add-on, which opens a new tab.
$("viewerContainer").addEventListener("click", (e) => {
const a = e.target?.closest?.("a[href]");
if (!a) return;
const href = a.getAttribute("href") || "";
if (!/^https?:/i.test(href)) return;
e.preventDefault();
e.stopPropagation();
sm?.invoke?.("openExternal", { url: href })
.catch((err) => toast(`Could not open that link: ${err?.message || err}`, true));
}, true);
// ---- keyboard ---------------------------------------------------------
// Ctrl+F, Ctrl+B and Ctrl +/-/0 never reach this page — Theseus claims them
// app-wide for find, the add-on sidebar and tab zoom. PDF zoom therefore lives
// on bare +/-/0, which is what desktop readers use anyway.
const TOOL_KEYS = {
v: "select", h: "highlight", u: "underline", k: "strikeout", p: "pen",
r: "rect", o: "ellipse", l: "line", a: "arrow", t: "text", g: "signature",
e: "edittext",
};
window.addEventListener("keydown", (e) => {
// A modal owns the keyboard while it is up: Escape cancels, and Enter
// commits unless the focus is in the textarea, where it means a newline.
if (!$("scrim").hidden) {
if (e.key === "Escape") { closeModal(null); e.preventDefault(); return; }
if (e.key === "Enter" && e.target?.tagName !== "TEXTAREA" && !e.shiftKey) {
const go = document.querySelector(".modal:not([hidden]) .btn.primary:not(:disabled)");
if (go) { go.click(); e.preventDefault(); }
}
return;
}
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName || "") || e.target?.isContentEditable;
const meta = e.ctrlKey || e.metaKey;
if (meta && e.key.toLowerCase() === "s") { e.preventDefault(); doSave(); return; }
if (meta && e.key.toLowerCase() === "o") { e.preventDefault(); pick(); return; }
if (meta && e.key.toLowerCase() === "d") {
e.preventDefault();
if (session.tools?.duplicateSelected()) paintChrome();
return;
}
if (meta && e.key.toLowerCase() === "z") {
e.preventDefault();
if (e.shiftKey) session.model?.redo(); else session.model?.undo();
afterHistory();
return;
}
if (typing) return;
// Everything you added to the page you are looking at. Not the whole
// document: a group can only be moved within one page, so selecting across
// pages would offer a group half of which cannot come along.
if (meta && e.key.toLowerCase() === "a") {
e.preventDefault();
selectAllOnPage();
return;
}
if (e.key === "Escape") {
// Step back one level at a time: drop the selection first, and only then
// disarm the tool. Doing both at once loses whichever the user meant.
if (session.tools?.selected) { session.tools.select(null); e.preventDefault(); return; }
if (session.tools?.tool !== "select") { selectTool("select"); e.preventDefault(); }
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (session.tools?.deleteSelected()) { paintChrome(); e.preventDefault(); }
return;
}
if (e.key === "Enter") {
const sel = session.tools?.selected;
if (session.tools?.selection?.size > 1) return; // one caret, one mark
const a = sel && session.model?.annot(sel);
if (a?.kind === "text") { beginText({ id: sel }); e.preventDefault(); return; }
if (a?.kind === "textedit") { onTextModal(sel, null); e.preventDefault(); }
return;
}
if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; }
if (e.key === "-") { session.strip?.stepScale(-1); syncZoomSelect(); e.preventDefault(); return; }
if (e.key === "0") { $("zoom").value = "page-width"; session.strip?.setScaleMode("page-width"); paintPageInfo(); e.preventDefault(); return; }
const t = TOOL_KEYS[e.key.toLowerCase()];
if (t) { document.querySelector(`.tool[data-tool="${t}"]`)?.click(); e.preventDefault(); }
});
// ---- start ------------------------------------------------------------
(async function start() {
try {
redactAcknowledged = (await sm?.storage?.get("redactAcknowledged", false)) === true;
const saved = await sm?.storage?.get("signatures", null);
if (Array.isArray(saved)) signatures = saved.filter((s) => s && Array.isArray(s.strokes));
// One signature used to live under its own key. Carry it into the
// library rather than leaving someone's only signature behind.
const legacy = await sm?.storage?.get("signature", null);
if (!signatures.length && legacy && Array.isArray(legacy.strokes)) {
signatures = [{ id: "sig-legacy", name: "Signature", ...legacy }];
await persistSignatures();
}
savedSignature = signatures[0] || null;
} catch {}
const id = new URLSearchParams(location.search).get("doc");
if (!id) { status("Drop a PDF or some pictures here, or pick one of the options."); paintChrome(); return; }
if (!sm?.invoke) { status("This page has to be opened from the PDF Editor add-on.", true); return; }
setBusy(true, "Fetching…");
try {
const res = await sm.invoke("getBytes", { id });
const bytes = res.data instanceof Uint8Array ? res.data : new Uint8Array(res.data);
await loadBytes(bytes, res.name || "document.pdf", id);
} catch (e) {
setBusy(false);
status(`Could not load that document: ${e?.message || e}`, true);
paintChrome();
}
})();