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