diff --git a/bundled-addons/pdf-editor/addon.json b/bundled-addons/pdf-editor/addon.json
index ba30ae1..f75a66b 100644
--- a/bundled-addons/pdf-editor/addon.json
+++ b/bundled-addons/pdf-editor/addon.json
@@ -1,25 +1,25 @@
-{
- "id": "pdf-editor",
- "name": "PDF Editor",
- "version": "0.1.2",
- "description": "Open a PDF in a full tab: read it, highlight and mark it up, reorder or rotate or drop pages, fill in its form fields, then save a new copy. Annotations are written into the saved file as real PDF content. The original is never touched.",
- "author": "Silent Mode",
- "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
- "main": "index.js",
- "capabilities": ["toolbar-menu", "open-tab", "context-menu-item"],
- "toolbar-menu": {
- "title": "PDF Editor",
- "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
- "items": [
- { "id": "open", "label": "Open a PDF…" }
- ]
- },
- "context-menu-items": [
- {
- "id": "open-link",
- "label": "Open PDF in editor",
- "when": "linkURL"
- }
- ],
- "updateURL": "https://navigate.st/bns/theseus.x/extensions/pdf-editor/updates.json"
-}
+{
+ "id": "pdf-editor",
+ "name": "PDF Editor",
+ "version": "0.2.0",
+ "description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Marks are written into the saved file as real PDF content. The original is never touched.",
+ "author": "Silent Mode",
+ "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
+ "main": "index.js",
+ "capabilities": ["toolbar-menu", "open-tab", "context-menu-item"],
+ "toolbar-menu": {
+ "title": "PDF Editor",
+ "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
+ "items": [
+ { "id": "open", "label": "Open a PDF…" }
+ ]
+ },
+ "context-menu-items": [
+ {
+ "id": "open-link",
+ "label": "Open PDF in editor",
+ "when": "linkURL"
+ }
+ ],
+ "updateURL": "https://navigate.st/bns/theseus.x/extensions/pdf-editor/updates.json"
+}
diff --git a/bundled-addons/pdf-editor/editor.css b/bundled-addons/pdf-editor/editor.css
index e5ddaaa..a6bb2d9 100644
--- a/bundled-addons/pdf-editor/editor.css
+++ b/bundled-addons/pdf-editor/editor.css
@@ -188,11 +188,11 @@ body[data-dragover="1"] .dz-inner { border-color: rgb(from var(--acid) r g b / .
/* Our overlay sits above the canvas but below the annotation (form) layer, so
a form field stays clickable even with marks drawn around it. */
-/* Clipped to the page, because the saved file is too: a PDF viewer shows
- nothing outside the crop box, so a mark that spilled past the edge on
- screen would simply vanish on save. */
+/* Marks are clipped to the page by a clipPath on the marks group, not by the
+ SVG's own overflow — the selection handles sit just outside a mark's bounds
+ and a mark flush with the page edge still has to be grabbable. */
.pdfViewer .page .smOverlay { position: absolute; inset: 0; z-index: 3;
- pointer-events: none; overflow: hidden; }
+ pointer-events: none; overflow: visible; }
.pdfViewer .page .annotationLayer { z-index: 4; }
.pdfViewer .page .textLayer { z-index: 2; }
@@ -225,3 +225,47 @@ body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all;
.annotationLayer .textWidgetAnnotation input,
.annotationLayer .textWidgetAnnotation textarea,
.annotationLayer .choiceWidgetAnnotation select { font: inherit; }
+
+/* ---- selection bar --------------------------------------------------- */
+/* Pinned to the current selection in viewport coordinates, so a page edge
+ never clips it and moving the selection to another page needs no
+ re-parenting. It steps aside mid-drag rather than jittering under the
+ cursor. */
+.selbar {
+ position: fixed; z-index: 40; display: flex; align-items: center; gap: 2px;
+ padding: 3px; border-radius: 8px; background: var(--panel);
+ border: 1px solid var(--line); box-shadow: 0 6px 22px var(--shadow);
+ user-select: none;
+}
+body[data-dragging="1"] .selbar,
+.selbar.offscreen { opacity: 0; pointer-events: none; }
+.selbar .sb {
+ border: 1px solid transparent; background: transparent; color: var(--ink);
+ min-width: 26px; height: 26px; border-radius: 5px; cursor: pointer; padding: 0 5px;
+ display: inline-flex; align-items: center; justify-content: center;
+ font: inherit; font-size: 13px; line-height: 1;
+}
+.selbar .sb svg { width: 15px; height: 15px; display: block; }
+.selbar .sb:hover { background: var(--panel2); border-color: rgb(from var(--acid) r g b / .5); }
+.selbar .sb.active { background: rgb(from var(--acid) r g b / .16);
+ border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
+.selbar .sb.danger:hover { border-color: rgba(255,91,91,.6); color: var(--danger); }
+.selbar .sb-group { display: inline-flex; align-items: center; gap: 1px; }
+.selbar .sb-val { min-width: 22px; text-align: center; font-size: 11.5px;
+ color: var(--mut); font-variant-numeric: tabular-nums; }
+.selbar .sb-sep { width: 1px; height: 17px; background: var(--line); margin: 0 3px; }
+
+/* Style toggles inside the text dialog. */
+.btn.styl { width: 30px; font-size: 14px; line-height: 1; }
+.btn.styl.active { background: rgb(from var(--acid) r g b / .16);
+ border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
+
+/* Resize handles. Only grabbable with the select tool armed, so a drawing
+ drag near a selected mark is never stolen by one. */
+body[data-mode="select"] .smOverlay .handle { pointer-events: all; }
+.smOverlay .handle { pointer-events: none; }
+
+/* The invisible grab area under a thin stroke. `stroke` rather than
+ `visibleStroke`: the copy is transparent on purpose. */
+body[data-mode="select"] .smOverlay .mark .hit { pointer-events: stroke; }
+.smOverlay .mark .hit { pointer-events: none; }
diff --git a/bundled-addons/pdf-editor/editor.html b/bundled-addons/pdf-editor/editor.html
index f4ae57a..7e23eb6 100644
--- a/bundled-addons/pdf-editor/editor.html
+++ b/bundled-addons/pdf-editor/editor.html
@@ -1,216 +1,259 @@
-
-
-
-
-PDF Editor
-
-
-
-
-
-
-
-
-
- No document
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Drop a PDF here
-
or
-
-
-
-
-
-
-
-
-
-
-
-
-
-
About redaction
-
A redaction box hides content visually. On its own it does not remove the
- text underneath, which stays extractable by anyone who copies from the file.
-
To actually remove it, this editor rebuilds each redacted page as an image when you save.
- That page's text stops being selectable and any form fields on it become static. Pages you
- did not redact are untouched.
-
-
-
-
-
-
-
-
-
Draw your signature
-
Draw in the box, then place it on the page. It is stored as lines, not a
- picture, so it stays sharp at any zoom.
-
-
-
-
-
-
-
-
-
-
-
Text stamp
-
-
-
-
-
-
-
-
-
-
-
-
Save a copy
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+PDF Editor
+
+
+
+
+
+
+
+
+
+ No document
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drop a PDF here
+
or
+
+
+
+
+
+
+
+
+
+
+
+
+
+
About redaction
+
A redaction box hides content visually. On its own it does not remove the
+ text underneath, which stays extractable by anyone who copies from the file.
+
To actually remove it, this editor rebuilds each redacted page as an image when you save.
+ That page's text stops being selectable and any form fields on it become static. Pages you
+ did not redact are untouched.
+
+
+
+
+
+
+
+
+
Draw your signature
+
Draw in the box, then place it on the page. It is stored as lines, not a
+ picture, so it stays sharp at any zoom.
+
+
+
+
+
+
+
+
+
+
+
Text stamp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Save a copy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 12
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundled-addons/pdf-editor/editor.js b/bundled-addons/pdf-editor/editor.js
index 4238201..5548bf9 100644
--- a/bundled-addons/pdf-editor/editor.js
+++ b/bundled-addons/pdf-editor/editor.js
@@ -1,707 +1,867 @@
-// 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 { buildPdf, unencodableStamps } from "./lib/save.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: "document.pdf",
-};
-
-// ---- 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;
- $("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(); },
- openTextModal: onTextModal,
- openSignaturePad: onSignaturePad,
- 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 -------------------------------------------------------
-async function openFile(file) {
- if (!file) return;
- const looksPdf = /\.pdf$/i.test(file.name) || file.type === "application/pdf";
- if (!looksPdf) { 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);
-}
-
-$("file-input").addEventListener("change", (e) => {
- const f = e.target.files?.[0];
- e.target.value = "";
- openFile(f);
-});
-const pick = () => $("file-input").click();
-$("open").addEventListener("click", pick);
-$("open2").addEventListener("click", pick);
-
-// 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 = "";
- openFile(e.dataTransfer.files?.[0]);
-});
-
-// ---- 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;
-
-for (const b of document.querySelectorAll(".tool[data-tool]")) {
- b.addEventListener("click", async () => {
- const t = b.dataset.tool;
- 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 {}
- }
- for (const o of document.querySelectorAll(".tool")) o.classList.toggle("active", o === b);
- session.tools?.setTool(t);
- status(TOOL_HINTS[t] || "");
- });
-}
-$("redact-ok").addEventListener("click", () => closeModal(true));
-$("redact-cancel").addEventListener("click", () => closeModal(null));
-
-const TOOL_HINTS = {
- select: "Click a mark to select it, drag to move it, Delete to remove it.",
- highlight: "Drag across text to highlight it.",
- pen: "Drag to draw.",
- rect: "Drag to draw a rectangle.",
- ellipse: "Drag to draw an ellipse.",
- arrow: "Drag from tail to head.",
- text: "Click where the text should start.",
- 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);
- });
-}
-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();
- session.tools.selected = null;
- session.strip.refreshAllOverlays(null);
- paintChrome();
- paintPageInfo();
-}
-
-$("zoom").addEventListener("change", (e) => {
- const v = e.target.value;
- session.strip?.setScaleMode(/^[\d.]+$/.test(v) ? parseFloat(v) : v);
- paintPageInfo();
-});
-$("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); });
-$("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); });
-function syncZoomSelect() {
- 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();
- }, 140);
-});
-
-// ---- text stamp -------------------------------------------------------
-async function onTextModal(at) {
- $("text-body").value = "";
- $("text-warn").hidden = true;
- const p = openModal("modal-text");
- setTimeout(() => $("text-body").focus(), 30);
- const ok = await p;
- if (!ok) return null;
- const text = $("text-body").value;
- const size = parseFloat($("text-size").value) || 12;
- return session.tools.placeText(at, text, size);
-}
-$("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;
-}
-
-// ---- signature --------------------------------------------------------
-let savedSignature = null;
-let pad = null;
-async function onSignaturePad() {
- if (!pad) {
- pad = new SignaturePad($("sign-pad"));
- pad.onChange((has) => { $("sign-ok").disabled = !has; });
- }
- pad.clear();
- const ok = await openModal("modal-sign");
- if (!ok) return null;
- const res = pad.result();
- if (!res) return null;
- savedSignature = res;
- try { await sm?.storage?.set("signature", res); } catch {}
- return res;
-}
-$("sign-ok").addEventListener("click", () => closeModal(true));
-$("sign-cancel").addEventListener("click", () => closeModal(null));
-$("sign-clear").addEventListener("click", () => pad?.clear());
-
-// ---- 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 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));
-
-// ---- 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", p: "pen", r: "rect", o: "ellipse", a: "arrow", t: "text", g: "signature" };
-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() === "z") {
- e.preventDefault();
- if (e.shiftKey) session.model?.redo(); else session.model?.undo();
- afterHistory();
- return;
- }
- if (typing) return;
-
- if (e.key === "Escape") {
- if (session.tools?.tool !== "select") {
- document.querySelector('.tool[data-tool="select"]')?.click();
- e.preventDefault();
- }
- return;
- }
- if (e.key === "Delete" || e.key === "Backspace") {
- if (session.tools?.deleteSelected()) { paintChrome(); 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 sig = await sm?.storage?.get("signature", null);
- if (sig && Array.isArray(sig.strokes)) savedSignature = sig;
- } catch {}
-
- const id = new URLSearchParams(location.search).get("doc");
- if (!id) { status("Drop a PDF here, or use Open."); 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();
- }
-})();
+// 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 { buildPdf, unencodableStamps } from "./lib/save.js";
+import { boundsOf, clampFont } from "./lib/shape.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: "document.pdf",
+};
+
+// ---- 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;
+ $("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,
+ editText: onTextModal,
+ openSignaturePad: onSignaturePad,
+ 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 -------------------------------------------------------
+async function openFile(file) {
+ if (!file) return;
+ const looksPdf = /\.pdf$/i.test(file.name) || file.type === "application/pdf";
+ if (!looksPdf) { 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);
+}
+
+$("file-input").addEventListener("change", (e) => {
+ const f = e.target.files?.[0];
+ e.target.value = "";
+ openFile(f);
+});
+const pick = () => $("file-input").click();
+$("open").addEventListener("click", pick);
+$("open2").addEventListener("click", pick);
+
+// 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 = "";
+ openFile(e.dataTransfer.files?.[0]);
+});
+
+// ---- 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;
+ 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 to select it. Drag to move, drag a handle to resize, double-click text to edit.",
+ 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.",
+ 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);
+});
+
+// ---- 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) {
+ const a = id && session.model ? session.model.annot(id) : null;
+ if (!a) { selbar.hidden = true; return; }
+ const isText = a.kind === "text";
+ 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; }
+ const b = boundsOf(a);
+ 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", () => positionSelectionBar(), { passive: true });
+
+$("sb-edit").addEventListener("click", () => {
+ const id = session.tools?.selected;
+ if (id) onTextModal(id, null);
+});
+$("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));
+
+function toggleTextStyle(which) {
+ const a = session.model?.annot(session.tools?.selected);
+ if (!a || a.kind !== "text") return;
+ delete a._w; // the glyphs change width; re-measure
+ session.tools.patchSelected({ [which]: !a[which] });
+}
+function stepTextSize(dir) {
+ const a = session.model?.annot(session.tools?.selected);
+ if (!a || a.kind !== "text") return;
+ const steps = [6, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96];
+ const at = a.size || 12;
+ const next = dir > 0 ? steps.find((v) => v > at + 0.01) : [...steps].reverse().find((v) => v < at - 0.01);
+ 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.
+ */
+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 : "";
+ $("text-size").value = String(existing ? existing.size || 12 : 12);
+ $("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;
+ if (!ok) return null;
+ const props = {
+ text: $("text-body").value,
+ size: parseFloat($("text-size").value) || 12,
+ bold: $("text-bold").classList.contains("active"),
+ italic: $("text-italic").classList.contains("active"),
+ };
+ 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;
+}
+
+// ---- signature --------------------------------------------------------
+let savedSignature = null;
+let pad = null;
+async function onSignaturePad() {
+ if (!pad) {
+ pad = new SignaturePad($("sign-pad"));
+ pad.onChange((has) => { $("sign-ok").disabled = !has; });
+ }
+ pad.clear();
+ const ok = await openModal("modal-sign");
+ if (!ok) return null;
+ const res = pad.result();
+ if (!res) return null;
+ savedSignature = res;
+ try { await sm?.storage?.set("signature", res); } catch {}
+ return res;
+}
+$("sign-ok").addEventListener("click", () => closeModal(true));
+$("sign-cancel").addEventListener("click", () => closeModal(null));
+$("sign-clear").addEventListener("click", () => pad?.clear());
+
+// ---- 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 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));
+
+// ---- 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",
+};
+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;
+
+ 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;
+ const a = sel && session.model?.annot(sel);
+ if (a?.kind === "text") { 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 sig = await sm?.storage?.get("signature", null);
+ if (sig && Array.isArray(sig.strokes)) savedSignature = sig;
+ } catch {}
+
+ const id = new URLSearchParams(location.search).get("doc");
+ if (!id) { status("Drop a PDF here, or use Open."); 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();
+ }
+})();
diff --git a/bundled-addons/pdf-editor/lib/overlay.js b/bundled-addons/pdf-editor/lib/overlay.js
index e65bb20..8261396 100644
--- a/bundled-addons/pdf-editor/lib/overlay.js
+++ b/bundled-addons/pdf-editor/lib/overlay.js
@@ -1,197 +1,322 @@
-// The on-screen layer that draws marks over a rendered page.
-//
-// One SVG per page, sized to that page's current viewport in CSS pixels.
-// Every mark is stored in PDF user space, so drawing means running each point
-// through `viewport.convertToViewportPoint` — which already accounts for the
-// page's scale AND its rotation. That is why rotating a page needs no special
-// handling here: the viewport changes, we re-render, and the marks turn with
-// the content they were drawn on.
-//
-// SVG rather than a canvas because marks have to be individually
-// hit-testable, selectable and movable, and because it stays crisp when the
-// user zooms without us re-rasterising anything.
-
-import { arrowBarbs, boundsOf, signatureStrokes, textLayout } from "./shape.js";
-
-const NS = "http://www.w3.org/2000/svg";
-function el(name, attrs) {
- const n = document.createElementNS(NS, name);
- for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
- return n;
-}
-
-export class Overlay {
- /** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
- constructor(pageDiv) {
- this.svg = el("svg", { class: "smOverlay", xmlns: NS });
- this.marks = el("g", { class: "marks" });
- this.chrome = el("g", { class: "chrome" }); // selection box + handles
- this.svg.append(this.marks, this.chrome);
- pageDiv.append(this.svg);
- this.viewport = null;
- }
-
- destroy() { this.svg.remove(); }
-
- /** Map a user-space point to CSS pixels inside the page div. */
- toView(x, y) {
- const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
- return [vx, vy];
- }
- /** Map a page-relative CSS pixel point back to user space. */
- toPdf(vx, vy) {
- const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
- return [x, y];
- }
-
- render(viewport, annots, selectedId) {
- this.viewport = viewport;
- this.svg.setAttribute("width", viewport.width);
- this.svg.setAttribute("height", viewport.height);
- this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
- this.marks.textContent = "";
- for (const a of annots) {
- const node = this._draw(a);
- if (!node) continue;
- node.classList.add("mark");
- node.dataset.id = a.id;
- if (a.id === selectedId) node.classList.add("sel");
- this.marks.append(node);
- }
- this._drawChrome(annots.find((a) => a.id === selectedId) || null);
- }
-
- // Scale a user-space length (a stroke width, a font size) into CSS pixels.
- // The viewport's scale already carries zoom; rotation does not change length.
- get k() { return this.viewport ? this.viewport.scale : 1; }
-
- _draw(a) {
- const g = el("g", {});
- switch (a.kind) {
- case "highlight": {
- for (const r of a.rects || []) {
- const [x0, y1] = this.toView(r.x, r.y + r.h);
- const [x1, y0] = this.toView(r.x + r.w, r.y);
- g.append(el("rect", {
- x: Math.min(x0, x1), y: Math.min(y0, y1),
- width: Math.abs(x1 - x0), height: Math.abs(y0 - y1),
- fill: a.color, "fill-opacity": a.opacity ?? 0.38,
- style: "mix-blend-mode:multiply",
- }));
- }
- g.dataset.fill = "1";
- return g;
- }
- case "pen": {
- const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
- if (pts.length < 2) return null;
- g.append(el("polyline", {
- points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
- fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
- "stroke-linecap": "round", "stroke-linejoin": "round",
- }));
- return g;
- }
- case "rect": {
- const b = this._box(a);
- g.append(el("rect", { ...b, fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k }));
- return g;
- }
- case "ellipse": {
- const b = this._box(a);
- g.append(el("ellipse", {
- cx: b.x + b.width / 2, cy: b.y + b.height / 2,
- rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
- fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
- }));
- return g;
- }
- case "arrow": {
- const [x1, y1] = this.toView(a.x1, a.y1);
- const [x2, y2] = this.toView(a.x2, a.y2);
- const w = (a.width || 2) * this.k;
- const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y));
- g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" }));
- g.append(el("polyline", {
- points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
- fill: "none", stroke: a.color, "stroke-width": w,
- "stroke-linecap": "round", "stroke-linejoin": "round",
- }));
- return g;
- }
- case "text": {
- const size = (a.size || 12) * this.k;
- let widest = 0;
- for (const line of textLayout(a)) {
- const [vx, vy] = this.toView(line.x, line.y);
- const t = el("text", {
- x: vx, y: vy, fill: a.color,
- "font-family": "Helvetica, Arial, sans-serif",
- "font-size": size, "xml:space": "preserve",
- });
- t.textContent = line.text;
- g.append(t);
- }
- g.dataset.fill = "1";
- // Measure once attached so the selection box is tight. Cached on the
- // mark in user-space units; boundsOf() reads it.
- requestAnimationFrame(() => {
- try {
- for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
- if (widest > 0) a._w = widest / this.k;
- } catch {}
- });
- return g;
- }
- case "signature": {
- const w = (a.width || 1.6) * this.k;
- for (const stroke of signatureStrokes(a)) {
- if (stroke.length < 2) continue;
- g.append(el("polyline", {
- points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "),
- fill: "none", stroke: a.color, "stroke-width": w,
- "stroke-linecap": "round", "stroke-linejoin": "round",
- }));
- }
- return g;
- }
- case "redact": {
- const b = this._box(a);
- g.append(el("rect", { ...b, fill: "#000" }));
- g.dataset.fill = "1";
- return g;
- }
- default: return null;
- }
- }
-
- // A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
- // corners so a rotated viewport lands the right way up.
- _box(a) {
- const [ax, ay] = this.toView(a.x, a.y);
- const [bx, by] = this.toView(a.x + a.w, a.y + a.h);
- return { x: Math.min(ax, bx), y: Math.min(ay, by),
- width: Math.abs(bx - ax), height: Math.abs(by - ay) };
- }
-
- _drawChrome(sel) {
- this.chrome.textContent = "";
- if (!sel) return;
- const b = boundsOf(sel);
- const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h });
- const pad = 3;
- this.chrome.append(el("rect", {
- class: "selbox", x: r.x - pad, y: r.y - pad,
- width: r.width + pad * 2, height: r.height + pad * 2,
- }));
- }
-
- /** The mark id under a page-relative CSS pixel point, or null. */
- hitTest(vx, vy) {
- const node = document.elementFromPoint(
- vx + this.svg.getBoundingClientRect().left,
- vy + this.svg.getBoundingClientRect().top);
- const mark = node?.closest?.(".mark");
- return mark ? mark.dataset.id : null;
- }
-}
+// The on-screen layer that draws marks over a rendered page.
+//
+// One SVG per page, sized to that page's current viewport in CSS pixels.
+// Every mark is stored in PDF user space, so drawing means running each point
+// through `viewport.convertToViewportPoint` — which already accounts for the
+// page's scale AND its rotation. That is why rotating a page needs no special
+// handling here: the viewport changes, we re-render, and the marks turn with
+// the content they were drawn on.
+//
+// SVG rather than a canvas because marks have to be individually
+// hit-testable, selectable and movable, and because it stays crisp when the
+// user zooms without us re-rasterising anything.
+
+import { arrowBarbs, boundsOf, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
+
+const NS = "http://www.w3.org/2000/svg";
+const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
+let clipSeq = 0; // clipPath ids must be unique across every page in the document
+const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
+function el(name, attrs) {
+ const n = document.createElementNS(NS, name);
+ for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
+ return n;
+}
+
+export class Overlay {
+ /** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
+ constructor(pageDiv) {
+ this.svg = el("svg", { class: "smOverlay", xmlns: NS });
+ // Marks are clipped to the page, because a PDF reader shows nothing
+ // outside the crop box and a mark that spilled over the edge on screen
+ // would vanish on save. The selection chrome is NOT clipped: a mark
+ // flush against the edge still needs grabbable handles, and those sit
+ // just outside its bounds.
+ this.clipId = `smclip-${++clipSeq}`;
+ const defs = el("defs", {});
+ this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 });
+ const clip = el("clipPath", { id: this.clipId });
+ clip.append(this.clipRect);
+ defs.append(clip);
+ this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` });
+ this.chrome = el("g", { class: "chrome" }); // selection box + handles
+ this.svg.append(defs, this.marks, this.chrome);
+ pageDiv.append(this.svg);
+ this.viewport = null;
+ }
+
+ destroy() { this.svg.remove(); }
+
+ /** Map a user-space point to CSS pixels inside the page div. */
+ toView(x, y) {
+ const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
+ return [vx, vy];
+ }
+ /** Map a page-relative CSS pixel point back to user space. */
+ toPdf(vx, vy) {
+ const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
+ return [x, y];
+ }
+
+ render(viewport, annots, selectedId) {
+ this.viewport = viewport;
+ this._selId = selectedId;
+ this.svg.setAttribute("width", viewport.width);
+ this.svg.setAttribute("height", viewport.height);
+ this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
+ this.clipRect.setAttribute("width", viewport.width);
+ this.clipRect.setAttribute("height", viewport.height);
+ this.marks.textContent = "";
+ for (const a of annots) {
+ const node = this._draw(a);
+ if (!node) continue;
+ this._addHitArea(node, a);
+ node.classList.add("mark");
+ node.dataset.id = a.id;
+ if (a.id === selectedId) node.classList.add("sel");
+ this.marks.append(node);
+ }
+ this._drawChrome(annots.find((a) => a.id === selectedId) || null);
+ }
+
+ // Scale a user-space length (a stroke width, a font size) into CSS pixels.
+ // The viewport's scale already carries zoom; rotation does not change length.
+ get k() { return this.viewport ? this.viewport.scale : 1; }
+
+ _draw(a) {
+ const g = el("g", {});
+ switch (a.kind) {
+ case "highlight": {
+ for (const r of a.rects || []) {
+ const b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h });
+ g.append(el("rect", {
+ ...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38,
+ style: "mix-blend-mode:multiply",
+ }));
+ }
+ g.dataset.fill = "1";
+ return g;
+ }
+ // Underline and strikeout share the highlight's geometry — one rect per
+ // line of selected text — and differ only in where the rule sits inside
+ // that rect. Keeping them on the same shape means a selection spanning a
+ // page break splits the same way for all three.
+ case "underline": case "strikeout": {
+ const frac = RULE_OFFSET[a.kind];
+ for (const r of a.rects || []) {
+ const yy = r.y + r.h * frac;
+ const [x1, y1] = this.toView(r.x, yy);
+ const [x2, y2] = this.toView(r.x + r.w, yy);
+ g.append(el("line", {
+ x1, y1, x2, y2, stroke: a.color,
+ "stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt",
+ }));
+ }
+ return g;
+ }
+ case "pen": {
+ const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
+ if (pts.length < 2) return null;
+ g.append(el("polyline", {
+ points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
+ fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
+ "stroke-linecap": "round", "stroke-linejoin": "round",
+ }));
+ return g;
+ }
+ case "rect": {
+ const b = this._box(a);
+ g.append(el("rect", { ...b, ...this._paint(a) }));
+ if (a.fill) g.dataset.fill = "1";
+ return g;
+ }
+ case "ellipse": {
+ const b = this._box(a);
+ g.append(el("ellipse", {
+ cx: b.x + b.width / 2, cy: b.y + b.height / 2,
+ rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
+ ...this._paint(a),
+ }));
+ if (a.fill) g.dataset.fill = "1";
+ return g;
+ }
+ case "line": {
+ const [x1, y1] = this.toView(a.x1, a.y1);
+ const [x2, y2] = this.toView(a.x2, a.y2);
+ g.append(el("line", {
+ x1, y1, x2, y2, stroke: a.color,
+ "stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round",
+ }));
+ return g;
+ }
+ case "arrow": {
+ const [x1, y1] = this.toView(a.x1, a.y1);
+ const [x2, y2] = this.toView(a.x2, a.y2);
+ const w = (a.width || 2) * this.k;
+ const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y));
+ g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" }));
+ g.append(el("polyline", {
+ points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
+ fill: "none", stroke: a.color, "stroke-width": w,
+ "stroke-linecap": "round", "stroke-linejoin": "round",
+ }));
+ return g;
+ }
+ case "text": {
+ const size = (a.size || 12) * this.k;
+ let widest = 0;
+ for (const line of textLayout(a)) {
+ const [vx, vy] = this.toView(line.x, line.y);
+ const t = el("text", {
+ x: vx, y: vy, fill: a.color,
+ "font-family": "Helvetica, Arial, sans-serif",
+ "font-size": size, "xml:space": "preserve",
+ "font-weight": a.bold ? "bold" : "normal",
+ "font-style": a.italic ? "italic" : "normal",
+ });
+ t.textContent = line.text;
+ g.append(t);
+ }
+ g.dataset.fill = "1";
+ // A string's width is only knowable once the browser has laid it out,
+ // so it is measured a frame later and cached on the mark in user-space
+ // units for boundsOf(). The selection box is drawn from that, and was
+ // drawn before the measurement existed — so redraw it once the real
+ // width is in, or the box and its handles sit at the fallback guess.
+ requestAnimationFrame(() => {
+ try {
+ for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
+ if (widest <= 0) return;
+ const w = widest / this.k;
+ const changed = Math.abs((a._w ?? 0) - w) > 0.5;
+ a._w = w;
+ if (changed && a.id === this._selId) this._drawChrome(a);
+ } catch {}
+ });
+ return g;
+ }
+ case "signature": {
+ const w = (a.width || 1.6) * this.k;
+ for (const stroke of signatureStrokes(a)) {
+ if (stroke.length < 2) continue;
+ g.append(el("polyline", {
+ points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "),
+ fill: "none", stroke: a.color, "stroke-width": w,
+ "stroke-linecap": "round", "stroke-linejoin": "round",
+ }));
+ }
+ return g;
+ }
+ case "redact": {
+ const b = this._box(a);
+ g.append(el("rect", { ...b, fill: "#000" }));
+ g.dataset.fill = "1";
+ return g;
+ }
+ default: return null;
+ }
+ }
+
+ /**
+ * Give a mark something to grab.
+ *
+ * A 2 pt outline is about one screen pixel: selecting it means hitting a
+ * hairline exactly, which is miserable with a mouse and impossible with a
+ * trackpad. Each stroked shape gets an invisible copy of itself with a fat
+ * stroke underneath, purely to catch the pointer. `pointer-events: stroke`
+ * hit-tests the stroke area whatever the paint is, so a transparent one
+ * still counts.
+ */
+ _addHitArea(g, a) {
+ if (g.dataset.fill === "1") return; // already a solid target
+ const wide = Math.max(HIT_TOLERANCE, (a.width || 2) * this.k * 1.8);
+ const clones = [];
+ for (const child of g.children) {
+ if (child.tagName === "text") continue;
+ const c = child.cloneNode(false);
+ c.setAttribute("stroke", "transparent");
+ c.setAttribute("stroke-width", wide);
+ c.setAttribute("fill", "none");
+ c.removeAttribute("style");
+ c.setAttribute("class", "hit");
+ clones.push(c);
+ }
+ for (const c of clones) g.insertBefore(c, g.firstChild);
+ }
+
+ // A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
+ // corners so a rotated viewport lands the right way up.
+ _box(a) {
+ const [ax, ay] = this.toView(a.x, a.y);
+ const [bx, by] = this.toView(a.x + a.w, a.y + a.h);
+ return { x: Math.min(ax, bx), y: Math.min(ay, by),
+ width: Math.abs(bx - ax), height: Math.abs(by - ay) };
+ }
+
+ // The selection outline and its grab handles.
+ //
+ // Handles are drawn at a fixed pixel size rather than scaled with the page,
+ // so they stay grabbable at 50% zoom and do not swell into the artwork at
+ // 400%. Each carries data-handle, which is what tools.js hit-tests to tell a
+ // resize from a move.
+ _drawChrome(sel) {
+ this.chrome.textContent = "";
+ if (!sel) return;
+ const b = boundsOf(sel);
+ const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h });
+ const pad = 3;
+ this.chrome.append(el("rect", {
+ class: "selbox", x: r.x - pad, y: r.y - pad,
+ width: r.width + pad * 2, height: r.height + pad * 2,
+ }));
+
+ const names = handlesFor(sel);
+ if (SEGMENT_KINDS.has(sel.kind)) {
+ // A line's ends are the only meaningful grips; a bounding box would let
+ // you stretch it in ways that never match what you were aiming at.
+ const ends = { p1: this.toView(sel.x1, sel.y1), p2: this.toView(sel.x2, sel.y2) };
+ for (const n of names) this._handle(n, ends[n][0], ends[n][1], "move");
+ return;
+ }
+ const L = r.x - pad, R = r.x + r.width + pad;
+ const T = r.y - pad, B = r.y + r.height + pad;
+ const MX = (L + R) / 2, MY = (T + B) / 2;
+ const at = { nw: [L, T], n: [MX, T], ne: [R, T], e: [R, MY],
+ se: [R, B], s: [MX, B], sw: [L, B], w: [L, MY] };
+ const cursor = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize",
+ n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
+ for (const n of names) {
+ const p = at[n];
+ if (p) this._handle(n, p[0], p[1], cursor[n]);
+ }
+ }
+
+ _handle(name, cx, cy, cursor) {
+ const h = el("rect", {
+ class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2,
+ width: HANDLE, height: HANDLE, rx: 1.5,
+ style: `cursor:${cursor}`,
+ });
+ h.dataset.handle = name;
+ this.chrome.append(h);
+ }
+
+ // Stroke and fill for a shape, honouring its fill and opacity.
+ _paint(a) {
+ return {
+ fill: a.fill ? a.color : "none",
+ "fill-opacity": a.fill ? (a.opacity ?? 0.25) : 0,
+ stroke: a.color,
+ "stroke-width": (a.width || 2) * this.k,
+ };
+ }
+
+ /** The mark id under a page-relative CSS pixel point, or null. */
+ hitTest(vx, vy) {
+ const node = document.elementFromPoint(
+ vx + this.svg.getBoundingClientRect().left,
+ vy + this.svg.getBoundingClientRect().top);
+ const mark = node?.closest?.(".mark");
+ return mark ? mark.dataset.id : null;
+ }
+}
diff --git a/bundled-addons/pdf-editor/lib/save.js b/bundled-addons/pdf-editor/lib/save.js
index 3048d0b..e476069 100644
--- a/bundled-addons/pdf-editor/lib/save.js
+++ b/bundled-addons/pdf-editor/lib/save.js
@@ -1,274 +1,305 @@
-// 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 } 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 "rect":
- page.drawRectangle({ x: a.x, y: a.y, width: a.w, height: a.h,
- borderColor: colorOf(a.color), borderWidth: a.width || 2, opacity: 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, opacity: 0 });
- 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;
- 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, 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} 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,
- });
- const font = await doc.embedFont(StandardFonts.Helvetica);
- 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, font); report.marks++; }
- for (const a of marks) if (a.kind === "redact") { drawMark(page, a, font); 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 };
-}
+// 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} 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 };
+}
diff --git a/bundled-addons/pdf-editor/lib/shape.js b/bundled-addons/pdf-editor/lib/shape.js
index 9f46029..ece559e 100644
--- a/bundled-addons/pdf-editor/lib/shape.js
+++ b/bundled-addons/pdf-editor/lib/shape.js
@@ -1,111 +1,208 @@
-// Geometry shared by the on-screen overlay and the pdf-lib writer.
-//
-// Both need to agree exactly on where an arrowhead's barbs land, where each
-// line of a text stamp sits, and how a normalised signature stroke maps into
-// its placed box. Keeping that arithmetic in one place is what makes "what I
-// drew" and "what got saved" the same picture; two copies of it drift the
-// first time either side is touched.
-//
-// Everything here works in PDF user space: points, origin bottom-left, y up.
-
-/** Approximate ascent as a fraction of font size, for Helvetica. */
-export const ASCENT = 0.76;
-/** Line advance as a fraction of font size. */
-export const LINE_GAP = 1.2;
-
-/**
- * Where each line of a text stamp is drawn. `a.y` is the TOP of the block —
- * that is what the user clicked — so every baseline hangs below it.
- * @returns {{text:string, x:number, y:number}[]} y is the baseline.
- */
-export function textLayout(a) {
- const lines = String(a.text ?? "").split("\n");
- const size = a.size || 12;
- return lines.map((text, i) => ({
- text,
- x: a.x,
- y: a.y - size * ASCENT - i * size * LINE_GAP,
- }));
-}
-
-/** Height of a text stamp's block, for hit-testing and the selection box. */
-export function textBlockHeight(a) {
- const lines = String(a.text ?? "").split("\n").length;
- const size = a.size || 12;
- return size * ASCENT + (lines - 1) * size * LINE_GAP + size * 0.24;
-}
-
-/**
- * The two barb points of an arrowhead at (x2,y2) coming from (x1,y1).
- * Head length scales with stroke width but is floored so a thin arrow still
- * reads as an arrow.
- */
-export function arrowBarbs(x1, y1, x2, y2, width) {
- const dx = x2 - x1, dy = y2 - y1;
- const len = Math.hypot(dx, dy) || 1;
- const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5));
- const ux = dx / len, uy = dy / len;
- // 24° either side of the shaft.
- const cos = Math.cos(0.42), sin = Math.sin(0.42);
- return [
- { x: x2 - head * (ux * cos - uy * sin), y: y2 - head * (uy * cos + ux * sin) },
- { x: x2 - head * (ux * cos + uy * sin), y: y2 - head * (uy * cos - ux * sin) },
- ];
-}
-
-/**
- * A placed signature's strokes in user space. Strokes are stored normalised
- * (0..1, y measured DOWN from the top of the pad) so the same drawing can be
- * dropped at any size, on any page, any number of times.
- */
-export function signatureStrokes(a) {
- const { x, y, w, h } = a;
- return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
-}
-
-/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
-export function normBox(x0, y0, x1, y1) {
- return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
-}
-
-/** Axis-aligned bounds of a mark, in user space. Used for selection and hit-testing. */
-export function boundsOf(a) {
- switch (a.kind) {
- case "highlight": {
- const rs = a.rects || [];
- if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
- const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
- const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
- return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
- }
- case "pen": {
- const pts = a.pts || [];
- if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
- const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
- const pad = (a.width || 2) / 2;
- return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
- w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
- }
- case "arrow": return normBox(a.x1, a.y1, a.x2, a.y2);
- case "text": {
- // Width is unknown without measuring the font; the renderer measures it
- // and caches it on the mark as `_w` so selection boxes are tight.
- return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
- }
- default: return { x: a.x, y: a.y, w: a.w, h: a.h };
- }
-}
-
-/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */
-export function translatePatch(a, dx, dy) {
- switch (a.kind) {
- case "highlight":
- return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
- case "pen":
- return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
- case "arrow":
- return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
- default:
- return { x: a.x + dx, y: a.y + dy };
- }
-}
+// Geometry shared by the on-screen overlay and the pdf-lib writer.
+//
+// Both need to agree exactly on where an arrowhead's barbs land, where each
+// line of a text stamp sits, and how a normalised signature stroke maps into
+// its placed box. Keeping that arithmetic in one place is what makes "what I
+// drew" and "what got saved" the same picture; two copies of it drift the
+// first time either side is touched.
+//
+// Everything here works in PDF user space: points, origin bottom-left, y up.
+
+/** Approximate ascent as a fraction of font size, for Helvetica. */
+export const ASCENT = 0.76;
+/** Line advance as a fraction of font size. */
+export const LINE_GAP = 1.2;
+
+/**
+ * Where each line of a text stamp is drawn. `a.y` is the TOP of the block —
+ * that is what the user clicked — so every baseline hangs below it.
+ * @returns {{text:string, x:number, y:number}[]} y is the baseline.
+ */
+export function textLayout(a) {
+ const lines = String(a.text ?? "").split("\n");
+ const size = a.size || 12;
+ return lines.map((text, i) => ({
+ text,
+ x: a.x,
+ y: a.y - size * ASCENT - i * size * LINE_GAP,
+ }));
+}
+
+/** Height of a text stamp's block, for hit-testing and the selection box. */
+export function textBlockHeight(a) {
+ const lines = String(a.text ?? "").split("\n").length;
+ const size = a.size || 12;
+ return size * ASCENT + (lines - 1) * size * LINE_GAP + size * 0.24;
+}
+
+/**
+ * The two barb points of an arrowhead at (x2,y2) coming from (x1,y1).
+ * Head length scales with stroke width but is floored so a thin arrow still
+ * reads as an arrow.
+ */
+export function arrowBarbs(x1, y1, x2, y2, width) {
+ const dx = x2 - x1, dy = y2 - y1;
+ const len = Math.hypot(dx, dy) || 1;
+ const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5));
+ const ux = dx / len, uy = dy / len;
+ // 24° either side of the shaft.
+ const cos = Math.cos(0.42), sin = Math.sin(0.42);
+ return [
+ { x: x2 - head * (ux * cos - uy * sin), y: y2 - head * (uy * cos + ux * sin) },
+ { x: x2 - head * (ux * cos + uy * sin), y: y2 - head * (uy * cos - ux * sin) },
+ ];
+}
+
+/**
+ * A placed signature's strokes in user space. Strokes are stored normalised
+ * (0..1, y measured DOWN from the top of the pad) so the same drawing can be
+ * dropped at any size, on any page, any number of times.
+ */
+export function signatureStrokes(a) {
+ const { x, y, w, h } = a;
+ return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
+}
+
+/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
+export function normBox(x0, y0, x1, y1) {
+ return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
+}
+
+/**
+ * Where a rule sits inside a line's rectangle, as a fraction of its height
+ * from the baseline side. Shared by the renderer and the writer so that what
+ * is on screen and what lands in the file are the same picture — two copies of
+ * this number is two pictures the first time either is touched.
+ */
+export const RULE_OFFSET = { underline: 0.06, strikeout: 0.42 };
+
+/** Marks whose geometry is a list of text-line rectangles. */
+export const RECT_LIST_KINDS = new Set(["highlight", "underline", "strikeout"]);
+/** Marks whose geometry is a single box. */
+export const BOX_KINDS = new Set(["rect", "ellipse", "redact", "signature"]);
+/** Marks defined by two endpoints. */
+export const SEGMENT_KINDS = new Set(["arrow", "line"]);
+
+export function boundsOf(a) {
+ if (RECT_LIST_KINDS.has(a.kind)) {
+ const rs = a.rects || [];
+ if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
+ const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
+ const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
+ }
+ if (SEGMENT_KINDS.has(a.kind)) return normBox(a.x1, a.y1, a.x2, a.y2);
+ switch (a.kind) {
+ case "pen": {
+ const pts = a.pts || [];
+ if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
+ const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
+ const pad = (a.width || 2) / 2;
+ return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
+ w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
+ }
+ case "text": {
+ // Width is unknown without measuring the font; the renderer measures it
+ // and caches it on the mark as `_w` so selection boxes are tight.
+ return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
+ }
+ default: return { x: a.x, y: a.y, w: a.w, h: a.h };
+ }
+}
+
+/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */
+export function translatePatch(a, dx, dy) {
+ if (RECT_LIST_KINDS.has(a.kind)) {
+ return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
+ }
+ if (SEGMENT_KINDS.has(a.kind)) {
+ return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
+ }
+ if (a.kind === "pen") return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
+ return { x: a.x + dx, y: a.y + dy };
+}
+
+// ---- resizing ---------------------------------------------------------
+//
+// Handles are named by the corner or edge they sit on, in screen terms: "nw"
+// is top-left as the reader sees it. User space has y running the other way,
+// so "n" moves the box's y + h and "s" moves its y. Keeping the names in
+// reader terms means the cursor CSS and the drag maths agree with what the
+// user is doing with the mouse.
+
+export const BOX_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
+export const CORNER_HANDLES = ["nw", "ne", "se", "sw"];
+
+/** Smallest box a resize may produce, in points. */
+export const MIN_EXTENT = 4;
+export const MIN_FONT = 4;
+export const MAX_FONT = 288;
+
+/** Which handles a mark offers. Segments get their two endpoints instead. */
+export function handlesFor(a) {
+ if (SEGMENT_KINDS.has(a.kind)) return ["p1", "p2"];
+ if (a.kind === "text") return CORNER_HANDLES; // uniform scale only
+ return BOX_HANDLES;
+}
+
+/**
+ * The box a drag of `handle` produces, given the mark's current bounds and a
+ * user-space delta. Anchors the opposite corner or edge, and never inverts:
+ * dragging the top edge past the bottom stops at the minimum rather than
+ * flipping the mark over, which is disorienting mid-gesture.
+ */
+export function resizeBox(b, handle, dx, dy, { uniform = false } = {}) {
+ let { x, y, w, h } = b;
+ const east = handle.includes("e"), west = handle.includes("w");
+ const north = handle.includes("n"), south = handle.includes("s");
+ if (uniform && (east || west) && (north || south)) {
+ // Corner drag on a uniform mark: one scale factor, taken from whichever
+ // axis moved further, so the mark never distorts.
+ const sx = (w + (east ? dx : -dx)) / (w || 1);
+ const sy = (h + (north ? dy : -dy)) / (h || 1);
+ const k = Math.max(MIN_EXTENT / Math.max(w, h, 1), Math.abs(sx) > Math.abs(sy) ? sx : sy);
+ const nw = Math.max(MIN_EXTENT, w * k), nh = Math.max(MIN_EXTENT, h * k);
+ return {
+ x: west ? x + w - nw : x,
+ y: south ? y + h - nh : y,
+ w: nw, h: nh,
+ };
+ }
+ if (east) w = Math.max(MIN_EXTENT, w + dx);
+ if (west) { const nw2 = Math.max(MIN_EXTENT, w - dx); x = x + w - nw2; w = nw2; }
+ if (north) h = Math.max(MIN_EXTENT, h + dy);
+ if (south) { const nh2 = Math.max(MIN_EXTENT, h - dy); y = y + h - nh2; h = nh2; }
+ return { x, y, w, h };
+}
+
+/**
+ * Map a mark from one bounding box to another. One function for every kind,
+ * so a new mark type gets resizing for free as long as boundsOf knows it.
+ */
+export function scaleToBox(a, from, to) {
+ const sx = from.w > 0.001 ? to.w / from.w : 1;
+ const sy = from.h > 0.001 ? to.h / from.h : 1;
+ const fx = (x) => to.x + (x - from.x) * sx;
+ const fy = (y) => to.y + (y - from.y) * sy;
+
+ if (RECT_LIST_KINDS.has(a.kind)) {
+ return { rects: (a.rects || []).map((r) => ({ x: fx(r.x), y: fy(r.y), w: r.w * sx, h: r.h * sy })) };
+ }
+ if (SEGMENT_KINDS.has(a.kind)) {
+ return { x1: fx(a.x1), y1: fy(a.y1), x2: fx(a.x2), y2: fy(a.y2) };
+ }
+ if (a.kind === "pen") {
+ return { pts: (a.pts || []).map(([x, y]) => [fx(x), fy(y)]) };
+ }
+ if (a.kind === "text") {
+ // Text scales by font size, not by stretching glyphs. `y` is the top of
+ // the block, which is where the new box's top is.
+ const size = clampFont((a.size || 12) * (sy || 1));
+ return { size, x: to.x, y: to.y + to.h };
+ }
+ return { x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h) };
+}
+
+export function clampFont(n) {
+ return Math.min(MAX_FONT, Math.max(MIN_FONT, Math.round(n * 10) / 10));
+}
diff --git a/bundled-addons/pdf-editor/lib/tools.js b/bundled-addons/pdf-editor/lib/tools.js
index 0b09df3..f78221f 100644
--- a/bundled-addons/pdf-editor/lib/tools.js
+++ b/bundled-addons/pdf-editor/lib/tools.js
@@ -1,287 +1,429 @@
-// Pointer behaviour for every tool.
-//
-// The editor is modal: one tool is armed, and what a drag does depends only on
-// which. The alternative — inferring intent from what is under the cursor — is
-// what makes PDF annotators infuriating, because a drag meant to draw a box
-// selects a paragraph instead. Modality is enforced in CSS (see the
-// body[data-mode] rules): with a drawing tool armed, the text and form layers
-// stop taking pointer events entirely, so a drag cannot be stolen.
-//
-// Highlight is the deliberate exception. It works BY selecting text — the
-// text layer stays live, the browser does the selection, and on release we
-// convert the selection's client rects into user space. Rebuilding text
-// selection from scratch over pdf.js's span soup would be worse at it.
-
-import { boundsOf, normBox, translatePatch } from "./shape.js";
-
-const MIN_DRAG = 3; // CSS px before a press counts as a drag
-const SIG_WIDTH_PT = 170; // default placed width of a signature
-
-export class Tools {
- constructor({ strip, model, host }) {
- this.strip = strip;
- this.model = model;
- this.host = host; // editor.js: {toast, openTextModal, openSignaturePad, signature(), onChange}
- this.tool = "select";
- this.color = "#ffd400";
- this.width = 2.5;
- this.selected = null;
- this.drag = null;
- // Opening a second document builds a second Tools over the same container.
- // Without a way to unbind, the first one stays subscribed, keeps pointing
- // at a torn-down strip, and throws on the next click — so every listener
- // goes on one signal and destroy() drops the lot.
- this._abort = new AbortController();
- const opts = { signal: this._abort.signal };
- const c = strip.container;
- c.addEventListener("pointerdown", (e) => this.onDown(e), opts);
- c.addEventListener("pointermove", (e) => this.onMove(e), opts);
- window.addEventListener("pointerup", (e) => this.onUp(e), opts);
- }
-
- destroy() {
- this._abort.abort();
- this.drag = null;
- this.selected = null;
- }
-
- get mode() {
- if (this.tool === "select") return "select";
- if (this.tool === "highlight") return "highlight";
- if (this.tool === "text" || this.tool === "signature") return "stamp";
- return "draw";
- }
-
- setTool(t) {
- this.tool = t;
- this.selected = null;
- document.body.dataset.mode = this.mode;
- this.strip.refreshAllOverlays(null);
- this.host.onChange?.();
- }
- setColor(c) { this.color = c; if (this.selected) this._recolorSelected(c); }
- setWidth(w) { this.width = w; }
-
- _recolorSelected(c) {
- const a = this.model.annot(this.selected);
- if (!a || a.kind === "redact") return;
- this.model.moveAnnot(a.id, { color: c });
- this.strip.refreshAllOverlays(this.selected);
- }
-
- deleteSelected() {
- if (!this.selected) return false;
- const ok = this.model.removeAnnot(this.selected);
- this.selected = null;
- this.strip.refreshAllOverlays(null);
- return ok;
- }
-
- // ---- pointer ------------------------------------------------------
- onDown(e) {
- if (e.button !== 0) return;
- const hit = this.strip.pageAt(e.clientX, e.clientY);
- if (!hit) return;
- const ov = this.strip.overlayFor(hit.uid);
- if (!ov || !ov.viewport) return;
-
- if (this.tool === "highlight") { this._selDirty = false; return; } // let the browser select
-
- const [x, y] = ov.toPdf(hit.vx, hit.vy);
-
- if (this.tool === "select") {
- const id = e.target?.closest?.(".mark")?.dataset?.id || null;
- this.selected = id;
- this.strip.refreshAllOverlays(id);
- if (id) {
- const a = this.model.annot(id);
- this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y, orig: structuredClone(a), moved: false };
- e.preventDefault();
- }
- return;
- }
-
- if (this.tool === "text") {
- this.host.openTextModal({ uid: hit.uid, x, y });
- e.preventDefault();
- return;
- }
- if (this.tool === "signature") {
- this._placeSignature(hit.uid, x, y);
- e.preventDefault();
- return;
- }
-
- // Drawing tools: start a drag with a live preview.
- this.drag = { kind: this.tool, uid: hit.uid, x0: x, y0: y, x, y,
- pts: this.tool === "pen" ? [[x, y]] : null,
- cx: e.clientX, cy: e.clientY, moved: false };
- e.preventDefault();
- }
-
- onMove(e) {
- const d = this.drag;
- if (!d) return;
- const ov = this.strip.overlayFor(d.uid);
- if (!ov || !ov.viewport) return;
- const view = this.strip.views.get(d.uid);
- if (!view) { this.drag = null; return; }
- const rect = view.pv.div.getBoundingClientRect();
- // A drag that wanders off the page stays on it. Marks outside the page
- // box are invisible in any PDF reader, so letting one be drawn there
- // would only produce something that disappears on save.
- const cx = Math.min(Math.max(e.clientX, rect.left), rect.right);
- const cy = Math.min(Math.max(e.clientY, rect.top), rect.bottom);
- const [x, y] = ov.toPdf(cx - rect.left, cy - rect.top);
- d.x = x; d.y = y;
- if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
-
- if (d.kind === "move") {
- const a = this.model.annot(d.id);
- if (!a) return;
- const patch = translatePatch(d.orig, x - d.x0, y - d.y0);
- Object.assign(a, patch); // live, un-journalled; committed on up
- d.moved = true;
- this.strip.refreshOverlay(d.uid, d.id);
- return;
- }
- if (d.kind === "pen") { d.pts.push([x, y]); }
- this.strip.setPreview(d.uid, this._previewAnnot(d));
- }
-
- onUp(e) {
- if (this.tool === "highlight") { this._commitHighlight(); return; }
- const d = this.drag;
- this.drag = null;
- if (!d) return;
- this.strip.setPreview(null, null);
-
- if (d.kind === "move") {
- const a = this.model.annot(d.id);
- if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; }
- // Put the mark back where it started, then move it through the model so
- // the whole gesture lands on the undo stack as one step.
- const moved = structuredClone(a);
- Object.assign(a, d.orig);
- const patch = {};
- for (const k of Object.keys(moved)) if (k !== "id" && k !== "page" && k !== "kind") patch[k] = moved[k];
- this.model.moveAnnot(d.id, patch);
- this.strip.refreshAllOverlays(d.id);
- return;
- }
-
- const a = this._previewAnnot(d, true);
- if (a) {
- const made = this.model.addAnnot(a);
- this.selected = this.tool === "select" ? made.id : null;
- }
- this.strip.refreshAllOverlays(this.selected);
- this.host.onChange?.();
- }
-
- // The mark a drag currently describes. `final` rejects degenerate ones so a
- // stray click does not leave an invisible zero-size rectangle behind.
- _previewAnnot(d, final = false) {
- const common = { page: d.uid, color: this.color, width: this.width };
- switch (d.kind) {
- case "pen": {
- if (final && d.pts.length < 2) return null;
- return { kind: "pen", ...common, pts: d.pts.slice() };
- }
- case "arrow": {
- if (final && !d.moved) return null;
- return { kind: "arrow", ...common, x1: d.x0, y1: d.y0, x2: d.x, y2: d.y };
- }
- case "rect": case "ellipse": case "redact": {
- const b = normBox(d.x0, d.y0, d.x, d.y);
- if (final && (b.w < 2 || b.h < 2)) return null;
- if (d.kind === "redact") return { kind: "redact", page: d.uid, ...b };
- return { kind: d.kind, ...common, ...b };
- }
- default: return null;
- }
- }
-
- // ---- highlight ----------------------------------------------------
- // Take the live selection's client rects, drop them onto whichever page
- // each one sits on, and convert to user space. A selection spanning a page
- // break therefore produces one highlight mark per page, which is also what
- // the PDF format wants.
- _commitHighlight() {
- const sel = window.getSelection();
- if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
- const byPage = new Map();
- for (let i = 0; i < sel.rangeCount; i++) {
- for (const cr of sel.getRangeAt(i).getClientRects()) {
- if (cr.width < 0.5 || cr.height < 0.5) continue;
- const hit = this.strip.pageAt(cr.left + cr.width / 2, cr.top + cr.height / 2);
- if (!hit) continue;
- const ov = this.strip.overlayFor(hit.uid);
- if (!ov || !ov.viewport) continue;
- const r = this.strip.views.get(hit.uid).pv.div.getBoundingClientRect();
- const [ax, ay] = ov.toPdf(cr.left - r.left, cr.top - r.top);
- const [bx, by] = ov.toPdf(cr.right - r.left, cr.bottom - r.top);
- const box = normBox(ax, ay, bx, by);
- if (box.w < 0.5 || box.h < 0.5) continue;
- if (!byPage.has(hit.uid)) byPage.set(hit.uid, []);
- byPage.get(hit.uid).push(box);
- }
- }
- if (!byPage.size) return;
- for (const [uid, rects] of byPage) {
- this.model.addAnnot({ kind: "highlight", page: uid, color: this.color, opacity: 0.38, rects: mergeRows(rects) });
- }
- sel.removeAllRanges();
- this.strip.refreshAllOverlays(null);
- this.host.onChange?.();
- }
-
- // ---- stamps -------------------------------------------------------
- placeText({ uid, x, y }, text, size) {
- if (!String(text || "").trim()) return null;
- const a = this.model.addAnnot({ kind: "text", page: uid, color: this.color, size, x, y, text });
- this.strip.refreshAllOverlays(a.id);
- this.selected = a.id;
- this.host.onChange?.();
- return a;
- }
-
- async _placeSignature(uid, x, y) {
- let sig = this.host.signature();
- if (!sig) {
- sig = await this.host.openSignaturePad();
- if (!sig) return;
- }
- const w = SIG_WIDTH_PT;
- const h = w * (sig.aspect || 0.34);
- const a = this.model.addAnnot({
- kind: "signature", page: uid, color: this.color === "#ffd400" ? "#1a1f2b" : this.color,
- width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
- });
- this.selected = a.id;
- this.strip.refreshAllOverlays(a.id);
- this.host.onChange?.();
- }
-
- /** Bounds of the current selection, for the status line. */
- selectedBounds() {
- const a = this.model.annot(this.selected);
- return a ? boundsOf(a) : null;
- }
-}
-
-// Client rects for a text selection come one per text run, so a single
-// highlighted line arrives as a dozen slivers. Merging the ones that share a
-// baseline turns them back into one band per line — fewer objects in the saved
-// file, and no seams where two slivers overlap at 38% opacity.
-function mergeRows(rects) {
- const rows = [];
- for (const r of rects.slice().sort((a, b) => b.y - a.y || a.x - b.x)) {
- const row = rows.find((q) => Math.abs((q.y + q.h / 2) - (r.y + r.h / 2)) < Math.min(q.h, r.h) * 0.5);
- if (!row) { rows.push({ ...r }); continue; }
- const x0 = Math.min(row.x, r.x), x1 = Math.max(row.x + row.w, r.x + r.w);
- const y0 = Math.min(row.y, r.y), y1 = Math.max(row.y + row.h, r.y + r.h);
- row.x = x0; row.w = x1 - x0; row.y = y0; row.h = y1 - y0;
- }
- return rows;
-}
+// Pointer behaviour for every tool.
+//
+// The editor is modal: one tool is armed, and what a drag does depends only on
+// which. The alternative — inferring intent from what is under the cursor — is
+// what makes PDF annotators infuriating, because a drag meant to draw a box
+// selects a paragraph instead. Modality is enforced in CSS (see the
+// body[data-mode] rules): with a drawing tool armed, the text and form layers
+// stop taking pointer events entirely, so a drag cannot be stolen.
+//
+// The text-markup tools are the deliberate exception. Highlight, underline and
+// strikeout all work BY selecting text — the text layer stays live, the browser
+// does the selection, and on release we convert the selection's client rects
+// into user space. Rebuilding text selection over pdf.js's span soup would be
+// worse at it.
+//
+// Placing something one-shot — a text stamp, a signature — drops straight back
+// to the select tool with the new mark selected. Staying armed means the next
+// click stamps a second copy, which is almost never what was wanted, and it
+// puts the thing just placed immediately within reach of a nudge or a resize.
+
+import { boundsOf, normBox, translatePatch, resizeBox, scaleToBox } from "./shape.js";
+
+const MIN_DRAG = 3; // CSS px before a press counts as a drag
+const DOUBLE_CLICK_MS = 450; // window for a second click on the same mark
+const SIG_WIDTH_PT = 170; // default placed width of a signature
+
+/** Tools that mark up existing text rather than drawing free-hand. */
+export const TEXT_MARKUP = new Set(["highlight", "underline", "strikeout"]);
+/** Tools that place something with a single click. */
+const STAMP_TOOLS = new Set(["text", "signature"]);
+
+export class Tools {
+ constructor({ strip, model, host }) {
+ this.strip = strip;
+ this.model = model;
+ // host (editor.js): { toast, onChange, selectTool, onSelectionChange,
+ // editText, openSignaturePad, signature }
+ this.host = host;
+ this.tool = "select";
+ this.color = "#ffd400";
+ this.width = 2.5;
+ this.fill = false; // shapes: outline only, or filled
+ this.fillOpacity = 0.25;
+ this.selected = null;
+ this.drag = null;
+ // Opening a second document builds a second Tools over the same container.
+ // Without a way to unbind, the first one stays subscribed, keeps pointing
+ // at a torn-down strip, and throws on the next click — so every listener
+ // goes on one signal and destroy() drops the lot.
+ this._abort = new AbortController();
+ const opts = { signal: this._abort.signal };
+ const c = strip.container;
+ c.addEventListener("pointerdown", (e) => this.onDown(e), opts);
+ c.addEventListener("pointermove", (e) => this.onMove(e), opts);
+ window.addEventListener("pointerup", (e) => this.onUp(e), opts);
+ }
+
+ destroy() {
+ this._abort.abort();
+ this.drag = null;
+ this.selected = null;
+ }
+
+ get mode() {
+ if (this.tool === "select") return "select";
+ if (TEXT_MARKUP.has(this.tool)) return "highlight";
+ if (STAMP_TOOLS.has(this.tool)) return "stamp";
+ return "draw";
+ }
+
+ setTool(t) {
+ this.tool = t;
+ this._setSelected(null);
+ document.body.dataset.mode = this.mode;
+ this.host.onChange?.();
+ }
+
+ /** The one place selection changes, so the host never misses one. */
+ _setSelected(id) {
+ this.selected = id;
+ this.strip.refreshAllOverlays(id);
+ this.host.onSelectionChange?.(id);
+ }
+ select(id) { this._setSelected(id); }
+
+ setColor(c) {
+ this.color = c;
+ const a = this.model.annot(this.selected);
+ if (a && a.kind !== "redact") this.patchSelected({ color: c });
+ }
+ setFill(on) {
+ this.fill = !!on;
+ const a = this.model.annot(this.selected);
+ if (a && (a.kind === "rect" || a.kind === "ellipse")) this.patchSelected({ fill: this.fill });
+ }
+ setWidth(w) {
+ this.width = w;
+ const a = this.model.annot(this.selected);
+ if (a && a.kind !== "text" && a.kind !== "redact") this.patchSelected({ width: w });
+ }
+
+ /** Change the selected mark, as one undo step. */
+ patchSelected(patch) {
+ if (!this.selected) return false;
+ const ok = this.model.moveAnnot(this.selected, patch);
+ this.strip.refreshAllOverlays(this.selected);
+ this.host.onSelectionChange?.(this.selected);
+ this.host.onChange?.();
+ return ok;
+ }
+
+ deleteSelected() {
+ if (!this.selected) return false;
+ const ok = this.model.removeAnnot(this.selected);
+ this._setSelected(null);
+ this.host.onChange?.();
+ return ok;
+ }
+
+ /** Drop a copy of the selected mark, offset so it is visibly a second one. */
+ duplicateSelected() {
+ const a = this.model.annot(this.selected);
+ if (!a) return null;
+ const off = 12;
+ const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
+ delete copy.id;
+ delete copy._w;
+ const made = this.model.addAnnot(copy);
+ this._setSelected(made.id);
+ this.host.onChange?.();
+ return made;
+ }
+
+ // ---- pointer ------------------------------------------------------
+ onDown(e) {
+ if (e.button !== 0) return;
+
+ // A resize handle is checked before anything else: it sits outside the
+ // mark's own bounds, so the page under it may not even be the page the
+ // mark is on.
+ if (this.tool === "select") {
+ const handle = e.target?.closest?.(".handle")?.dataset?.handle;
+ if (handle && this.selected) {
+ const a = this.model.annot(this.selected);
+ const ov = a && this.strip.overlayFor(a.page);
+ if (a && ov?.viewport) {
+ const view = this.strip.views.get(a.page);
+ const r = view.pv.div.getBoundingClientRect();
+ const [hx, hy] = ov.toPdf(e.clientX - r.left, e.clientY - r.top);
+ this.drag = {
+ kind: "resize", uid: a.page, id: a.id, handle,
+ x0: hx, y0: hy, orig: structuredClone(a), from: boundsOf(a),
+ cx: e.clientX, cy: e.clientY, moved: false,
+ };
+ e.preventDefault();
+ return;
+ }
+ }
+ }
+
+ const hit = this.strip.pageAt(e.clientX, e.clientY);
+ if (!hit) return;
+ const ov = this.strip.overlayFor(hit.uid);
+ if (!ov || !ov.viewport) return;
+
+ if (TEXT_MARKUP.has(this.tool)) return; // let the browser select text
+
+ const [x, y] = ov.toPdf(hit.vx, hit.vy);
+
+ if (this.tool === "select") {
+ const id = e.target?.closest?.(".mark")?.dataset?.id || null;
+ // Double-click is detected here rather than from a `dblclick` listener.
+ // Selecting a mark calls preventDefault() on the pointerdown, and that
+ // suppresses the compatibility mouse events the browser would otherwise
+ // synthesise a dblclick from — so the native event never arrives.
+ const now = Date.now();
+ const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS;
+ this._lastClick = id ? { id, t: now } : null;
+
+ this._setSelected(id);
+ if (!id) return;
+ e.preventDefault();
+ if (double) {
+ const a = this.model.annot(id);
+ if (a?.kind === "text") { this.host.editText?.(id, null); return; }
+ }
+ this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y,
+ orig: structuredClone(this.model.annot(id)), cx: e.clientX, cy: e.clientY, moved: false };
+ return;
+ }
+
+ if (this.tool === "text") {
+ this.host.editText?.(null, { uid: hit.uid, x, y });
+ e.preventDefault();
+ return;
+ }
+ if (this.tool === "signature") {
+ this._placeSignature(hit.uid, x, y);
+ e.preventDefault();
+ return;
+ }
+
+ // Drawing tools: start a drag with a live preview.
+ this.drag = { kind: this.tool, uid: hit.uid, x0: x, y0: y, x, y,
+ pts: this.tool === "pen" ? [[x, y]] : null,
+ cx: e.clientX, cy: e.clientY, moved: false };
+ e.preventDefault();
+ }
+
+ onMove(e) {
+ const d = this.drag;
+ if (!d) return;
+ const ov = this.strip.overlayFor(d.uid);
+ if (!ov || !ov.viewport) return;
+ const view = this.strip.views.get(d.uid);
+ if (!view) { this.drag = null; return; }
+ const rect = view.pv.div.getBoundingClientRect();
+ // A drag that wanders off the page stays on it. Marks outside the page
+ // box are invisible in any PDF reader, so letting one be drawn there
+ // would only produce something that disappears on save.
+ const cx = Math.min(Math.max(e.clientX, rect.left), rect.right);
+ const cy = Math.min(Math.max(e.clientY, rect.top), rect.bottom);
+ const [x, y] = ov.toPdf(cx - rect.left, cy - rect.top);
+ d.x = x; d.y = y;
+ if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
+
+ if (d.kind === "move") {
+ const a = this.model.annot(d.id);
+ if (!a) return;
+ // Live and un-journalled; the whole gesture is committed once on release
+ // so undo steps back over the drag, not over each mouse move.
+ Object.assign(a, translatePatch(d.orig, x - d.x0, y - d.y0));
+ d.moved = true;
+ this.strip.refreshOverlay(d.uid, d.id);
+ return;
+ }
+ if (d.kind === "resize") {
+ const a = this.model.annot(d.id);
+ if (!a) return;
+ // Always mapped from the ORIGINAL geometry, never from the last frame,
+ // so a drag back to where it started restores the mark exactly.
+ if (d.handle === "p1" || d.handle === "p2") {
+ Object.assign(a, d.handle === "p1" ? { x1: x, y1: y } : { x2: x, y2: y });
+ } else {
+ const uniform = d.orig.kind === "text" || d.orig.kind === "signature";
+ const to = resizeBox(d.from, d.handle, x - d.x0, y - d.y0, { uniform });
+ Object.assign(a, scaleToBox(d.orig, d.from, to));
+ }
+ d.moved = true;
+ this.strip.refreshOverlay(d.uid, d.id);
+ return;
+ }
+ if (d.kind === "pen") { d.pts.push([x, y]); }
+ this.strip.setPreview(d.uid, this._previewAnnot(d));
+ }
+
+ onUp(e) {
+ if (TEXT_MARKUP.has(this.tool)) { this._commitTextMarkup(); return; }
+ const d = this.drag;
+ this.drag = null;
+ if (!d) return;
+ this.strip.setPreview(null, null);
+
+ if (d.kind === "move" || d.kind === "resize") {
+ const a = this.model.annot(d.id);
+ if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; }
+ this._commitLiveEdit(a, d.orig);
+ this._setSelected(d.id);
+ this.host.onChange?.();
+ return;
+ }
+
+ const a = this._previewAnnot(d, true);
+ if (a) {
+ const made = this.model.addAnnot(a);
+ this._setSelected(made.id);
+ } else {
+ this.strip.refreshAllOverlays(this.selected);
+ }
+ this.host.onChange?.();
+ }
+
+ // A live drag mutates the mark in place so the screen keeps up. To land it
+ // on the undo stack as ONE step, put the original back and re-apply the
+ // result through the model.
+ _commitLiveEdit(a, orig) {
+ const after = structuredClone(a);
+ Object.assign(a, orig);
+ const patch = {};
+ for (const k of Object.keys(after)) {
+ if (k !== "id" && k !== "page" && k !== "kind") patch[k] = after[k];
+ }
+ this.model.moveAnnot(a.id, patch);
+ }
+
+ // The mark a drag currently describes. `final` rejects degenerate ones so a
+ // stray click does not leave an invisible zero-size rectangle behind.
+ _previewAnnot(d, final = false) {
+ const common = { page: d.uid, color: this.color, width: this.width };
+ switch (d.kind) {
+ case "pen": {
+ if (final && d.pts.length < 2) return null;
+ return { kind: "pen", ...common, pts: d.pts.slice() };
+ }
+ case "arrow": case "line": {
+ if (final && !d.moved) return null;
+ return { kind: d.kind, ...common, x1: d.x0, y1: d.y0, x2: d.x, y2: d.y };
+ }
+ case "rect": case "ellipse": case "redact": {
+ const b = normBox(d.x0, d.y0, d.x, d.y);
+ if (final && (b.w < 2 || b.h < 2)) return null;
+ if (d.kind === "redact") return { kind: "redact", page: d.uid, ...b };
+ return { kind: d.kind, ...common, ...b, fill: !!this.fill, opacity: this.fillOpacity ?? 0.25 };
+ }
+ default: return null;
+ }
+ }
+
+ // ---- text markup --------------------------------------------------
+ // Take the live selection's client rects, drop them onto whichever page each
+ // one sits on, and convert to user space. A selection spanning a page break
+ // therefore produces one mark per page, which is also what the PDF format
+ // wants. Highlight, underline and strikeout differ only in `kind`.
+ _commitTextMarkup() {
+ const kind = this.tool;
+ const sel = window.getSelection();
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
+ const byPage = new Map();
+ for (let i = 0; i < sel.rangeCount; i++) {
+ for (const cr of sel.getRangeAt(i).getClientRects()) {
+ if (cr.width < 0.5 || cr.height < 0.5) continue;
+ const hit = this.strip.pageAt(cr.left + cr.width / 2, cr.top + cr.height / 2);
+ if (!hit) continue;
+ const ov = this.strip.overlayFor(hit.uid);
+ if (!ov || !ov.viewport) continue;
+ const r = this.strip.views.get(hit.uid).pv.div.getBoundingClientRect();
+ const [ax, ay] = ov.toPdf(cr.left - r.left, cr.top - r.top);
+ const [bx, by] = ov.toPdf(cr.right - r.left, cr.bottom - r.top);
+ const box = normBox(ax, ay, bx, by);
+ if (box.w < 0.5 || box.h < 0.5) continue;
+ if (!byPage.has(hit.uid)) byPage.set(hit.uid, []);
+ byPage.get(hit.uid).push(box);
+ }
+ }
+ if (!byPage.size) return;
+ let last = null;
+ for (const [uid, rects] of byPage) {
+ last = this.model.addAnnot({
+ kind, page: uid, color: this.color,
+ opacity: kind === "highlight" ? 0.38 : 1,
+ width: kind === "highlight" ? undefined : Math.max(0.8, this.width * 0.55),
+ rects: mergeRows(rects),
+ });
+ }
+ sel.removeAllRanges();
+ this._setSelected(last ? last.id : null);
+ this.host.onChange?.();
+ }
+
+ // ---- stamps -------------------------------------------------------
+ /** Place a new text mark, or rewrite an existing one, as one undo step. */
+ placeText({ uid, x, y }, props) {
+ if (!String(props.text || "").trim()) return null;
+ const a = this.model.addAnnot({
+ kind: "text", page: uid, color: this.color, x, y,
+ size: props.size, text: props.text, bold: !!props.bold, italic: !!props.italic,
+ });
+ this._afterStamp(a);
+ return a;
+ }
+
+ updateText(id, props) {
+ const a = this.model.annot(id);
+ if (!a) return null;
+ if (!String(props.text || "").trim()) { this.deleteSelected(); return null; }
+ this.model.moveAnnot(id, {
+ text: props.text, size: props.size, bold: !!props.bold, italic: !!props.italic,
+ });
+ delete a._w; // width changed; let the renderer re-measure
+ this._setSelected(id);
+ this.host.onChange?.();
+ return a;
+ }
+
+ async _placeSignature(uid, x, y) {
+ let sig = this.host.signature();
+ if (!sig) {
+ sig = await this.host.openSignaturePad();
+ if (!sig) return;
+ }
+ const w = SIG_WIDTH_PT;
+ const h = w * (sig.aspect || 0.34);
+ const a = this.model.addAnnot({
+ kind: "signature", page: uid, color: this.color === "#ffd400" ? "#1a1f2b" : this.color,
+ width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
+ });
+ this._afterStamp(a);
+ }
+
+ // Drop back to select with the new mark live, so it can be nudged, resized
+ // or deleted straight away and a second click does not stamp a duplicate.
+ _afterStamp(a) {
+ this.host.selectTool?.("select");
+ this._setSelected(a.id);
+ this.host.onChange?.();
+ }
+
+ /** Bounds of the current selection, for the status line. */
+ selectedBounds() {
+ const a = this.model.annot(this.selected);
+ return a ? boundsOf(a) : null;
+ }
+}
+
+// Client rects for a text selection come one per text run, so a single
+// highlighted line arrives as a dozen slivers. Merging the ones that share a
+// baseline turns them back into one band per line — fewer objects in the saved
+// file, and no seams where two slivers overlap at 38% opacity.
+function mergeRows(rects) {
+ const rows = [];
+ for (const r of rects.slice().sort((a, b) => b.y - a.y || a.x - b.x)) {
+ const row = rows.find((q) => Math.abs((q.y + q.h / 2) - (r.y + r.h / 2)) < Math.min(q.h, r.h) * 0.5);
+ if (!row) { rows.push({ ...r }); continue; }
+ const x0 = Math.min(row.x, r.x), x1 = Math.max(row.x + row.w, r.x + r.w);
+ const y0 = Math.min(row.y, r.y), y1 = Math.max(row.y + row.h, r.y + r.h);
+ row.x = x0; row.w = x1 - x0; row.y = y0; row.h = y1 - y0;
+ }
+ return rows;
+}