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
-
-
-
- - - -
- - - - - - -
- - - - - - + + + + +PDF Editor + + + + + + +
+ + + No document + + + +
+ + +
+
+
+ + + + + + + + + + + + +
+ +
+ + + + + +
+ +
+ + + + +
+ +
+ + +
+
+ +
+ + + + +
+
+ +
+ +
+
+ +
+
+ + + +
Drop a PDF here
+
or
+
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + 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; +}