feat(pdf-editor): a mark you placed is something you can still work on

Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.

Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.

Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.

Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.

New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.

Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
This commit is contained in:
Local Dev 2026-09-21 03:13:06 +02:00
parent cb7ffafcce
commit 0684906f13
8 changed files with 2463 additions and 1821 deletions

View file

@ -1,25 +1,25 @@
{ {
"id": "pdf-editor", "id": "pdf-editor",
"name": "PDF Editor", "name": "PDF Editor",
"version": "0.1.2", "version": "0.2.0",
"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.", "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", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"main": "index.js", "main": "index.js",
"capabilities": ["toolbar-menu", "open-tab", "context-menu-item"], "capabilities": ["toolbar-menu", "open-tab", "context-menu-item"],
"toolbar-menu": { "toolbar-menu": {
"title": "PDF Editor", "title": "PDF Editor",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"items": [ "items": [
{ "id": "open", "label": "Open a PDF…" } { "id": "open", "label": "Open a PDF…" }
] ]
}, },
"context-menu-items": [ "context-menu-items": [
{ {
"id": "open-link", "id": "open-link",
"label": "Open PDF in editor", "label": "Open PDF in editor",
"when": "linkURL" "when": "linkURL"
} }
], ],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pdf-editor/updates.json" "updateURL": "https://navigate.st/bns/theseus.x/extensions/pdf-editor/updates.json"
} }

View file

@ -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 /* Our overlay sits above the canvas but below the annotation (form) layer, so
a form field stays clickable even with marks drawn around it. */ 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 /* Marks are clipped to the page by a clipPath on the marks group, not by the
nothing outside the crop box, so a mark that spilled past the edge on SVG's own overflow — the selection handles sit just outside a mark's bounds
screen would simply vanish on save. */ and a mark flush with the page edge still has to be grabbable. */
.pdfViewer .page .smOverlay { position: absolute; inset: 0; z-index: 3; .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 .annotationLayer { z-index: 4; }
.pdfViewer .page .textLayer { z-index: 2; } .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 input,
.annotationLayer .textWidgetAnnotation textarea, .annotationLayer .textWidgetAnnotation textarea,
.annotationLayer .choiceWidgetAnnotation select { font: inherit; } .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; }

View file

@ -1,216 +1,259 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>PDF Editor</title> <title>PDF Editor</title>
<link rel="stylesheet" href="vendor/pdfjs/web/pdf_viewer.css"> <link rel="stylesheet" href="vendor/pdfjs/web/pdf_viewer.css">
<link rel="stylesheet" href="editor.css"> <link rel="stylesheet" href="editor.css">
</head> </head>
<body data-empty="1"> <body data-empty="1">
<!-- Top bar: document identity on the left, destructive/close actions right. <!-- Top bar: document identity on the left, destructive/close actions right.
Same split as the screenshot editor's topbar. --> Same split as the screenshot editor's topbar. -->
<div class="topbar"> <div class="topbar">
<button class="btn wide" id="open" title="Open a PDF (Ctrl+O)"> <button class="btn wide" id="open" title="Open a PDF (Ctrl+O)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h4l1.5 2H14v6H2z"/><path d="M2 5V3h5"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h4l1.5 2H14v6H2z"/><path d="M2 5V3h5"/></svg>
<span>Open</span> <span>Open</span>
</button> </button>
<span class="sep"></span> <span class="sep"></span>
<span class="docname" id="docname">No document</span> <span class="docname" id="docname">No document</span>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn wide primary" id="save" title="Save a copy (Ctrl+S)" disabled> <button class="btn wide primary" id="save" title="Save a copy (Ctrl+S)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
<span>Save a copy</span> <span>Save a copy</span>
</button> </button>
<button class="btn danger" id="discard" title="Close this tab without saving (Esc)"> <button class="btn danger" id="discard" title="Close this tab without saving (Esc)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
</button> </button>
</div> </div>
<!-- Tool bar. `.tool-cluster` centres the drawing groups so the view <!-- Tool bar. `.tool-cluster` centres the drawing groups so the view
controls can right-anchor without the tools crowding the left edge. --> controls can right-anchor without the tools crowding the left edge. -->
<div class="toolbar" id="toolbar"> <div class="toolbar" id="toolbar">
<div class="tool-cluster"> <div class="tool-cluster">
<div class="tgroup" role="group" aria-label="Tools"> <div class="tgroup" role="group" aria-label="Tools">
<button class="tool active" data-tool="select" title="Select — move or delete a mark, select text (V)"> <button class="tool active" data-tool="select" title="Select — move or delete a mark, select text (V)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2l4 11 1.6-4.4L13 7z"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2l4 11 1.6-4.4L13 7z"/></svg>
</button> </button>
<button class="tool" data-tool="highlight" title="Highlight — drag across text (H)"> <button class="tool" data-tool="highlight" title="Highlight — drag across text (H)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h10"/><path d="M5 9.5l4.5-6.5 3 2L8 11.5z"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h10"/><path d="M5 9.5l4.5-6.5 3 2L8 11.5z"/></svg>
</button> </button>
<button class="tool" data-tool="pen" title="Pen — freehand (P)"> <button class="tool" data-tool="underline" title="Underline — drag across text (U)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 2v5a4 4 0 0 0 8 0V2"/><path d="M3 14h10"/></svg>
</button> </button>
<button class="tool" data-tool="rect" title="Rectangle (R)"> <button class="tool" data-tool="strikeout" title="Strike through — drag across text (K)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 3.5h8"/><path d="M8 3.5V12"/><path d="M2.5 8h11"/></svg>
</button> </button>
<button class="tool" data-tool="ellipse" title="Ellipse (O)"> <button class="tool" data-tool="pen" title="Pen — freehand (P)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
</button> </button>
<button class="tool" data-tool="arrow" title="Arrow (A)"> <button class="tool" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg>
</button> </button>
<button class="tool" data-tool="text" title="Text stamp (T)"> <button class="tool" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
</button> </button>
<button class="tool" data-tool="signature" title="Signature stamp (G)"> <button class="tool" data-tool="line" title="Line (L)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 11c2 0 3-7 4.5-7S8 10 9.5 10 12 6 13.5 6"/><path d="M2 14h12"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 13L13 3"/></svg>
</button> </button>
<button class="tool" data-tool="redact" title="Redact — drag a box over content to remove it" data-warn="1"> <button class="tool" data-tool="arrow" title="Arrow (A)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="4.5" width="11" height="7" fill="currentColor"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
</button> </button>
</div> <button class="tool" data-tool="text" title="Text stamp (T)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
<div class="tgroup" role="group" aria-label="Colours"> </button>
<button class="swatch active" data-color="#ffd400" style="background:#ffd400" title="Yellow"></button> <button class="tool" data-tool="signature" title="Signature stamp (G)">
<button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 11c2 0 3-7 4.5-7S8 10 9.5 10 12 6 13.5 6"/><path d="M2 14h12"/></svg>
<button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button> </button>
<button class="swatch" data-color="#2bd97c" style="background:#2bd97c" title="Green"></button> <button class="tool" data-tool="redact" title="Redact — drag a box over content to remove it" data-warn="1">
<button class="swatch" data-color="#1a1f2b" style="background:#1a1f2b" title="Ink"></button> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="4.5" width="11" height="7" fill="currentColor"/></svg>
</div> </button>
</div>
<div class="tgroup" role="group" aria-label="Widths">
<button class="width" data-width="1.5" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button> <div class="tgroup" role="group" aria-label="Colours">
<button class="width active" data-width="2.5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button> <button class="swatch active" data-color="#ffd400" style="background:#ffd400" title="Yellow"></button>
<button class="width" data-width="4.5" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button> <button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
</div> <button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
<button class="swatch" data-color="#2bd97c" style="background:#2bd97c" title="Green"></button>
<div class="tgroup" role="group" aria-label="History"> <button class="swatch" data-color="#1a1f2b" style="background:#1a1f2b" title="Ink"></button>
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled> </div>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
</button> <div class="tgroup" role="group" aria-label="Widths">
<button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled> <button class="width" data-width="1.5" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg> <button class="width active" data-width="2.5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
</button> <button class="width" data-width="4.5" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
</div> <button class="btn" id="toggle-fill" title="Fill shapes as well as outline them">
</div> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/><path d="M2.5 9.5h11v3h-11z" fill="currentColor" stroke="none"/></svg>
</button>
<div class="tgroup zoomgroup" role="group" aria-label="Zoom"> </div>
<button class="btn" id="zoom-out" title="Zoom out ()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8h10"/></svg> <div class="tgroup" role="group" aria-label="History">
</button> <button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
<select id="zoom" title="Zoom"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
<option value="page-width">Fit width</option> </button>
<option value="page-fit">Fit page</option> <button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<option value="0.5">50%</option> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
<option value="0.75">75%</option> </button>
<option value="1">100%</option> </div>
<option value="1.25">125%</option> </div>
<option value="1.5">150%</option>
<option value="2">200%</option> <div class="tgroup zoomgroup" role="group" aria-label="Zoom">
<option value="4">400%</option> <button class="btn" id="zoom-out" title="Zoom out ()">
</select> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8h10"/></svg>
<button class="btn" id="zoom-in" title="Zoom in (+)"> </button>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg> <select id="zoom" title="Zoom">
</button> <option value="page-width">Fit width</option>
<button class="btn" id="toggle-rail" title="Show or hide the page thumbnails"> <option value="page-fit">Fit page</option>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="7" height="12"/><rect x="11" y="2" width="3" height="5"/><rect x="11" y="9" width="3" height="5"/></svg> <option value="0.5">50%</option>
</button> <option value="0.75">75%</option>
</div> <option value="1">100%</option>
</div> <option value="1.25">125%</option>
<option value="1.5">150%</option>
<div class="body"> <option value="2">200%</option>
<!-- Scroll container. pdf.js's own CSS keys off #viewer .page, and reads <option value="4">400%</option>
--scale-factor from the container, so the structure below mirrors what </select>
the pdf.js viewer builds. --> <button class="btn" id="zoom-in" title="Zoom in (+)">
<div class="viewerContainer" id="viewerContainer"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
<div id="viewer" class="pdfViewer"></div> </button>
<button class="btn" id="toggle-rail" title="Show or hide the page thumbnails">
<div class="dropzone" id="dropzone"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="7" height="12"/><rect x="11" y="2" width="3" height="5"/><rect x="11" y="9" width="3" height="5"/></svg>
<div class="dz-inner"> </button>
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> </div>
<path d="M12 6h16l8 8v28H12z"/><path d="M28 6v8h8"/><path d="M24 22v12M19 29l5 5 5-5"/> </div>
</svg>
<div class="dz-title">Drop a PDF here</div> <div class="body">
<div class="dz-sub">or <button class="linkish" id="open2">choose a file</button></div> <!-- Scroll container. pdf.js's own CSS keys off #viewer .page, and reads
</div> --scale-factor from the container, so the structure below mirrors what
</div> the pdf.js viewer builds. -->
</div> <div class="viewerContainer" id="viewerContainer">
<div id="viewer" class="pdfViewer"></div>
<!-- Page rail. Thumbnails are draggable to reorder; each carries rotate
and delete buttons. --> <div class="dropzone" id="dropzone">
<aside class="rail" id="rail"> <div class="dz-inner">
<div class="rail-head"> <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<span id="rail-count">0 pages</span> <path d="M12 6h16l8 8v28H12z"/><path d="M28 6v8h8"/><path d="M24 22v12M19 29l5 5 5-5"/>
<button class="fbtn" id="restore-pages" hidden title="Bring back every deleted page">Undo deletes</button> </svg>
</div> <div class="dz-title">Drop a PDF here</div>
<div class="rail-list" id="rail-list"></div> <div class="dz-sub">or <button class="linkish" id="open2">choose a file</button></div>
</aside> </div>
</div> </div>
</div>
<div class="footer">
<span class="fstat" id="status">Ready</span> <!-- Page rail. Thumbnails are draggable to reorder; each carries rotate
<span class="spacer"></span> and delete buttons. -->
<span class="fstat" id="pageinfo"></span> <aside class="rail" id="rail">
</div> <div class="rail-head">
<span id="rail-count">0 pages</span>
<!-- Modal shell, reused by the redaction warning, the signature pad and the <button class="fbtn" id="restore-pages" hidden title="Bring back every deleted page">Undo deletes</button>
text-stamp prompt. One at a time; `data-modal` names the open one. --> </div>
<div class="scrim" id="scrim" hidden> <div class="rail-list" id="rail-list"></div>
<div class="modal" id="modal-redact" hidden> </aside>
<h2>About redaction</h2> </div>
<p>A redaction box hides content visually. On its own it does <strong>not</strong> remove the
text underneath, which stays extractable by anyone who copies from the file.</p> <div class="footer">
<p>To actually remove it, this editor rebuilds each redacted page as an image when you save. <span class="fstat" id="status">Ready</span>
That page's text stops being selectable and any form fields on it become static. Pages you <span class="spacer"></span>
did not redact are untouched.</p> <span class="fstat" id="pageinfo"></span>
<label class="check"><input type="checkbox" id="redact-flatten" checked> </div>
<span>Rebuild redacted pages as images when saving (recommended)</span></label>
<div class="mrow"> <!-- Modal shell, reused by the redaction warning, the signature pad and the
<button class="btn wide" id="redact-cancel">Cancel</button> text-stamp prompt. One at a time; `data-modal` names the open one. -->
<button class="btn wide primary" id="redact-ok">Use the redaction tool</button> <div class="scrim" id="scrim" hidden>
</div> <div class="modal" id="modal-redact" hidden>
</div> <h2>About redaction</h2>
<p>A redaction box hides content visually. On its own it does <strong>not</strong> remove the
<div class="modal" id="modal-sign" hidden> text underneath, which stays extractable by anyone who copies from the file.</p>
<h2>Draw your signature</h2> <p>To actually remove it, this editor rebuilds each redacted page as an image when you save.
<p class="mhint">Draw in the box, then place it on the page. It is stored as lines, not a That page's text stops being selectable and any form fields on it become static. Pages you
picture, so it stays sharp at any zoom.</p> did not redact are untouched.</p>
<canvas id="sign-pad" width="640" height="220"></canvas> <label class="check"><input type="checkbox" id="redact-flatten" checked>
<div class="mrow"> <span>Rebuild redacted pages as images when saving (recommended)</span></label>
<button class="btn wide" id="sign-clear">Clear</button> <div class="mrow">
<span class="spacer"></span> <button class="btn wide" id="redact-cancel">Cancel</button>
<button class="btn wide" id="sign-cancel">Cancel</button> <button class="btn wide primary" id="redact-ok">Use the redaction tool</button>
<button class="btn wide primary" id="sign-ok" disabled>Use this signature</button> </div>
</div> </div>
</div>
<div class="modal" id="modal-sign" hidden>
<div class="modal" id="modal-text" hidden> <h2>Draw your signature</h2>
<h2>Text stamp</h2> <p class="mhint">Draw in the box, then place it on the page. It is stored as lines, not a
<textarea id="text-body" rows="4" placeholder="Type the text to place…"></textarea> picture, so it stays sharp at any zoom.</p>
<div class="mrow"> <canvas id="sign-pad" width="640" height="220"></canvas>
<label class="mfield">Size <div class="mrow">
<select id="text-size"> <button class="btn wide" id="sign-clear">Clear</button>
<option value="8">8</option><option value="10">10</option> <span class="spacer"></span>
<option value="12" selected>12</option><option value="14">14</option> <button class="btn wide" id="sign-cancel">Cancel</button>
<option value="18">18</option><option value="24">24</option><option value="36">36</option> <button class="btn wide primary" id="sign-ok" disabled>Use this signature</button>
</select> </div>
</label> </div>
<span class="spacer"></span>
<button class="btn wide" id="text-cancel">Cancel</button> <div class="modal" id="modal-text" hidden>
<button class="btn wide primary" id="text-ok">Place</button> <h2 id="modal-text-title">Text stamp</h2>
</div> <textarea id="text-body" rows="4" placeholder="Type the text to place…"></textarea>
<p class="mhint" id="text-warn" hidden></p> <div class="mrow">
</div> <label class="mfield">Size
<select id="text-size">
<div class="modal" id="modal-save" hidden> <option value="8">8</option><option value="10">10</option>
<h2>Save a copy</h2> <option value="12" selected>12</option><option value="14">14</option>
<label class="mfield block">File name <option value="18">18</option><option value="24">24</option>
<input type="text" id="save-name" spellcheck="false"> <option value="36">36</option><option value="48">48</option><option value="72">72</option>
</label> </select>
<ul class="summary" id="save-summary"></ul> </label>
<div class="mrow"> <button class="btn styl" id="text-bold" type="button" title="Bold"><b>B</b></button>
<span class="spacer"></span> <button class="btn styl" id="text-italic" type="button" title="Italic"><i>I</i></button>
<button class="btn wide" id="save-cancel">Cancel</button> <span class="spacer"></span>
<button class="btn wide primary" id="save-go">Save</button> <button class="btn wide" id="text-cancel">Cancel</button>
</div> <button class="btn wide primary" id="text-ok">Place</button>
</div> </div>
</div> <p class="mhint" id="text-warn" hidden></p>
</div>
<div class="toast" id="toast"></div>
<input type="file" id="file-input" accept="application/pdf,.pdf" hidden> <div class="modal" id="modal-save" hidden>
<a id="download-link" style="display:none"></a> <h2>Save a copy</h2>
<label class="mfield block">File name
<script type="module" src="editor.js"></script> <input type="text" id="save-name" spellcheck="false">
</body> </label>
</html> <ul class="summary" id="save-summary"></ul>
<div class="mrow">
<span class="spacer"></span>
<button class="btn wide" id="save-cancel">Cancel</button>
<button class="btn wide primary" id="save-go">Save</button>
</div>
</div>
</div>
<!-- Pinned to the current selection; see paintSelectionBar in editor.js. -->
<div class="selbar" id="selbar" hidden>
<button class="sb" id="sb-edit" title="Edit this text (or double-click it)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2.5l2.5 2.5L6 12.5 3 13l.5-3z"/></svg>
</button>
<span class="sb-group" id="sb-textsize">
<button class="sb" id="sb-smaller" title="Smaller">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3.5 8h9"/></svg>
</button>
<span class="sb-val" id="sb-size">12</span>
<button class="sb" id="sb-bigger" title="Bigger">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg>
</button>
</span>
<button class="sb" id="sb-bold" title="Bold"><b>B</b></button>
<button class="sb" id="sb-italic" title="Italic"><i>I</i></button>
<button class="sb" id="sb-fill" title="Fill">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/><path d="M2.5 9.5h11v3h-11z" fill="currentColor" stroke="none"/></svg>
</button>
<span class="sb-sep"></span>
<button class="sb" id="sb-dup" title="Duplicate">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5.5" y="5.5" width="8" height="8" rx="1"/><path d="M3.5 10.5V3a1 1 0 0 1 1-1H11"/></svg>
</button>
<button class="sb danger" id="sb-del" title="Delete (Del)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h10"/><path d="M6 4V2h4v2"/><path d="M4 4l1 10h6l1-10"/><path d="M7 6.5v5M9 6.5v5"/></svg>
</button>
</div>
<div class="toast" id="toast"></div>
<input type="file" id="file-input" accept="application/pdf,.pdf" hidden>
<a id="download-link" style="display:none"></a>
<script type="module" src="editor.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,197 +1,322 @@
// The on-screen layer that draws marks over a rendered page. // 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. // 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 // Every mark is stored in PDF user space, so drawing means running each point
// through `viewport.convertToViewportPoint` — which already accounts for the // through `viewport.convertToViewportPoint` — which already accounts for the
// page's scale AND its rotation. That is why rotating a page needs no special // 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 // handling here: the viewport changes, we re-render, and the marks turn with
// the content they were drawn on. // the content they were drawn on.
// //
// SVG rather than a canvas because marks have to be individually // SVG rather than a canvas because marks have to be individually
// hit-testable, selectable and movable, and because it stays crisp when the // hit-testable, selectable and movable, and because it stays crisp when the
// user zooms without us re-rasterising anything. // user zooms without us re-rasterising anything.
import { arrowBarbs, boundsOf, signatureStrokes, textLayout } from "./shape.js"; import { arrowBarbs, boundsOf, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
const NS = "http://www.w3.org/2000/svg"; const NS = "http://www.w3.org/2000/svg";
function el(name, attrs) { const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
const n = document.createElementNS(NS, name); let clipSeq = 0; // clipPath ids must be unique across every page in the document
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]); const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
return n; function el(name, attrs) {
} const n = document.createElementNS(NS, name);
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
export class Overlay { return n;
/** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */ }
constructor(pageDiv) {
this.svg = el("svg", { class: "smOverlay", xmlns: NS }); export class Overlay {
this.marks = el("g", { class: "marks" }); /** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
this.chrome = el("g", { class: "chrome" }); // selection box + handles constructor(pageDiv) {
this.svg.append(this.marks, this.chrome); this.svg = el("svg", { class: "smOverlay", xmlns: NS });
pageDiv.append(this.svg); // Marks are clipped to the page, because a PDF reader shows nothing
this.viewport = null; // 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
destroy() { this.svg.remove(); } // just outside its bounds.
this.clipId = `smclip-${++clipSeq}`;
/** Map a user-space point to CSS pixels inside the page div. */ const defs = el("defs", {});
toView(x, y) { this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 });
const [vx, vy] = this.viewport.convertToViewportPoint(x, y); const clip = el("clipPath", { id: this.clipId });
return [vx, vy]; clip.append(this.clipRect);
} defs.append(clip);
/** Map a page-relative CSS pixel point back to user space. */ this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` });
toPdf(vx, vy) { this.chrome = el("g", { class: "chrome" }); // selection box + handles
const [x, y] = this.viewport.convertToPdfPoint(vx, vy); this.svg.append(defs, this.marks, this.chrome);
return [x, y]; pageDiv.append(this.svg);
} this.viewport = null;
}
render(viewport, annots, selectedId) {
this.viewport = viewport; destroy() { this.svg.remove(); }
this.svg.setAttribute("width", viewport.width);
this.svg.setAttribute("height", viewport.height); /** Map a user-space point to CSS pixels inside the page div. */
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`); toView(x, y) {
this.marks.textContent = ""; const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
for (const a of annots) { return [vx, vy];
const node = this._draw(a); }
if (!node) continue; /** Map a page-relative CSS pixel point back to user space. */
node.classList.add("mark"); toPdf(vx, vy) {
node.dataset.id = a.id; const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
if (a.id === selectedId) node.classList.add("sel"); return [x, y];
this.marks.append(node); }
}
this._drawChrome(annots.find((a) => a.id === selectedId) || null); render(viewport, annots, selectedId) {
} this.viewport = viewport;
this._selId = selectedId;
// Scale a user-space length (a stroke width, a font size) into CSS pixels. this.svg.setAttribute("width", viewport.width);
// The viewport's scale already carries zoom; rotation does not change length. this.svg.setAttribute("height", viewport.height);
get k() { return this.viewport ? this.viewport.scale : 1; } this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
this.clipRect.setAttribute("width", viewport.width);
_draw(a) { this.clipRect.setAttribute("height", viewport.height);
const g = el("g", {}); this.marks.textContent = "";
switch (a.kind) { for (const a of annots) {
case "highlight": { const node = this._draw(a);
for (const r of a.rects || []) { if (!node) continue;
const [x0, y1] = this.toView(r.x, r.y + r.h); this._addHitArea(node, a);
const [x1, y0] = this.toView(r.x + r.w, r.y); node.classList.add("mark");
g.append(el("rect", { node.dataset.id = a.id;
x: Math.min(x0, x1), y: Math.min(y0, y1), if (a.id === selectedId) node.classList.add("sel");
width: Math.abs(x1 - x0), height: Math.abs(y0 - y1), this.marks.append(node);
fill: a.color, "fill-opacity": a.opacity ?? 0.38, }
style: "mix-blend-mode:multiply", this._drawChrome(annots.find((a) => a.id === selectedId) || null);
})); }
}
g.dataset.fill = "1"; // Scale a user-space length (a stroke width, a font size) into CSS pixels.
return g; // The viewport's scale already carries zoom; rotation does not change length.
} get k() { return this.viewport ? this.viewport.scale : 1; }
case "pen": {
const pts = (a.pts || []).map(([x, y]) => this.toView(x, y)); _draw(a) {
if (pts.length < 2) return null; const g = el("g", {});
g.append(el("polyline", { switch (a.kind) {
points: pts.map((p) => `${p[0]},${p[1]}`).join(" "), case "highlight": {
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k, for (const r of a.rects || []) {
"stroke-linecap": "round", "stroke-linejoin": "round", const b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h });
})); g.append(el("rect", {
return g; ...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38,
} style: "mix-blend-mode:multiply",
case "rect": { }));
const b = this._box(a); }
g.append(el("rect", { ...b, fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k })); g.dataset.fill = "1";
return g; return g;
} }
case "ellipse": { // Underline and strikeout share the highlight's geometry — one rect per
const b = this._box(a); // line of selected text — and differ only in where the rule sits inside
g.append(el("ellipse", { // that rect. Keeping them on the same shape means a selection spanning a
cx: b.x + b.width / 2, cy: b.y + b.height / 2, // page break splits the same way for all three.
rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2), case "underline": case "strikeout": {
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k, const frac = RULE_OFFSET[a.kind];
})); for (const r of a.rects || []) {
return g; const yy = r.y + r.h * frac;
} const [x1, y1] = this.toView(r.x, yy);
case "arrow": { const [x2, y2] = this.toView(r.x + r.w, yy);
const [x1, y1] = this.toView(a.x1, a.y1); g.append(el("line", {
const [x2, y2] = this.toView(a.x2, a.y2); x1, y1, x2, y2, stroke: a.color,
const w = (a.width || 2) * this.k; "stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt",
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", { return g;
points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`, }
fill: "none", stroke: a.color, "stroke-width": w, case "pen": {
"stroke-linecap": "round", "stroke-linejoin": "round", const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
})); if (pts.length < 2) return null;
return g; g.append(el("polyline", {
} points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
case "text": { fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
const size = (a.size || 12) * this.k; "stroke-linecap": "round", "stroke-linejoin": "round",
let widest = 0; }));
for (const line of textLayout(a)) { return g;
const [vx, vy] = this.toView(line.x, line.y); }
const t = el("text", { case "rect": {
x: vx, y: vy, fill: a.color, const b = this._box(a);
"font-family": "Helvetica, Arial, sans-serif", g.append(el("rect", { ...b, ...this._paint(a) }));
"font-size": size, "xml:space": "preserve", if (a.fill) g.dataset.fill = "1";
}); return g;
t.textContent = line.text; }
g.append(t); case "ellipse": {
} const b = this._box(a);
g.dataset.fill = "1"; g.append(el("ellipse", {
// Measure once attached so the selection box is tight. Cached on the cx: b.x + b.width / 2, cy: b.y + b.height / 2,
// mark in user-space units; boundsOf() reads it. rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
requestAnimationFrame(() => { ...this._paint(a),
try { }));
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength()); if (a.fill) g.dataset.fill = "1";
if (widest > 0) a._w = widest / this.k; return g;
} catch {} }
}); case "line": {
return g; const [x1, y1] = this.toView(a.x1, a.y1);
} const [x2, y2] = this.toView(a.x2, a.y2);
case "signature": { g.append(el("line", {
const w = (a.width || 1.6) * this.k; x1, y1, x2, y2, stroke: a.color,
for (const stroke of signatureStrokes(a)) { "stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round",
if (stroke.length < 2) continue; }));
g.append(el("polyline", { return g;
points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "), }
fill: "none", stroke: a.color, "stroke-width": w, case "arrow": {
"stroke-linecap": "round", "stroke-linejoin": "round", 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;
return g; 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" }));
case "redact": { g.append(el("polyline", {
const b = this._box(a); points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
g.append(el("rect", { ...b, fill: "#000" })); fill: "none", stroke: a.color, "stroke-width": w,
g.dataset.fill = "1"; "stroke-linecap": "round", "stroke-linejoin": "round",
return g; }));
} return g;
default: return null; }
} case "text": {
} const size = (a.size || 12) * this.k;
let widest = 0;
// A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both for (const line of textLayout(a)) {
// corners so a rotated viewport lands the right way up. const [vx, vy] = this.toView(line.x, line.y);
_box(a) { const t = el("text", {
const [ax, ay] = this.toView(a.x, a.y); x: vx, y: vy, fill: a.color,
const [bx, by] = this.toView(a.x + a.w, a.y + a.h); "font-family": "Helvetica, Arial, sans-serif",
return { x: Math.min(ax, bx), y: Math.min(ay, by), "font-size": size, "xml:space": "preserve",
width: Math.abs(bx - ax), height: Math.abs(by - ay) }; "font-weight": a.bold ? "bold" : "normal",
} "font-style": a.italic ? "italic" : "normal",
});
_drawChrome(sel) { t.textContent = line.text;
this.chrome.textContent = ""; g.append(t);
if (!sel) return; }
const b = boundsOf(sel); g.dataset.fill = "1";
const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h }); // A string's width is only knowable once the browser has laid it out,
const pad = 3; // so it is measured a frame later and cached on the mark in user-space
this.chrome.append(el("rect", { // units for boundsOf(). The selection box is drawn from that, and was
class: "selbox", x: r.x - pad, y: r.y - pad, // drawn before the measurement existed — so redraw it once the real
width: r.width + pad * 2, height: r.height + pad * 2, // 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());
/** The mark id under a page-relative CSS pixel point, or null. */ if (widest <= 0) return;
hitTest(vx, vy) { const w = widest / this.k;
const node = document.elementFromPoint( const changed = Math.abs((a._w ?? 0) - w) > 0.5;
vx + this.svg.getBoundingClientRect().left, a._w = w;
vy + this.svg.getBoundingClientRect().top); if (changed && a.id === this._selId) this._drawChrome(a);
const mark = node?.closest?.(".mark"); } catch {}
return mark ? mark.dataset.id : null; });
} 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;
}
}

View file

@ -1,274 +1,305 @@
// Turning the editing session back into a PDF, with pdf-lib. // Turning the editing session back into a PDF, with pdf-lib.
// //
// The source bytes are never mutated. Every save re-loads the original and // 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 // 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. // the next one and the file on disk stays whatever it was.
// //
// Step order is load-bearing: // Step order is load-bearing:
// //
// 1. form values — before anything strips a widget // 1. form values — before anything strips a widget
// 2. flatten — replaces a redacted page's content stream, which // 2. flatten — replaces a redacted page's content stream, which
// would wipe marks drawn in step 3 // would wipe marks drawn in step 3
// 3. marks — drawn against ORIGINAL page indices, which are still // 3. marks — drawn against ORIGINAL page indices, which are still
// valid because nothing has moved yet // valid because nothing has moved yet
// 4. page tree — reorder and delete last, so step 3's indices held // 4. page tree — reorder and delete last, so step 3's indices held
// 5. rotation — independent of order, done in the same pass // 5. rotation — independent of order, done in the same pass
// //
// Marks are drawn with pdf-lib's operator helpers rather than drawSvgPath. // Marks are drawn with pdf-lib's operator helpers rather than drawSvgPath.
// drawSvgPath takes an SVG-shaped path with y running downwards from an // drawSvgPath takes an SVG-shaped path with y running downwards from an
// anchor, so every mark would need a second coordinate convention and a // 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 // matching inverse in the overlay. Raw operators take the user-space points we
// already store, unchanged. // already store, unchanged.
import { import {
PDFDocument, PDFName, StandardFonts, rgb, degrees, PDFDocument, PDFName, StandardFonts, rgb, degrees,
pushGraphicsState, popGraphicsState, moveTo, lineTo, stroke, fill, pushGraphicsState, popGraphicsState, moveTo, lineTo, stroke, fill,
setStrokingColor, setFillingColor, setLineWidth, setLineCap, setLineJoin, setStrokingColor, setFillingColor, setLineWidth, setLineCap, setLineJoin,
LineCapStyle, LineJoinStyle, LineCapStyle, LineJoinStyle,
} from "../vendor/pdf-lib/pdf-lib.esm.min.js"; } from "../vendor/pdf-lib/pdf-lib.esm.min.js";
import { arrowBarbs, signatureStrokes, textLayout } from "./shape.js"; import { arrowBarbs, signatureStrokes, textLayout, RULE_OFFSET } from "./shape.js";
/** #rrggbb -> pdf-lib rgb(). */ /** #rrggbb -> pdf-lib rgb(). */
function colorOf(hex) { function colorOf(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "#000000")); const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "#000000"));
const n = m ? parseInt(m[1], 16) : 0; const n = m ? parseInt(m[1], 16) : 0;
return rgb(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); 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. // 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 // 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. // feature most stamps never need, so instead we substitute and say so.
const WIN_ANSI_OK = /^[\x20-\x7E\xA0-\xFF“”—•€…‰ŒœŠšŸŽžƒˆ˜™†‡‰]*$/; const WIN_ANSI_OK = /^[\x20-\x7E\xA0-\xFF“”—•€…‰ŒœŠšŸŽžƒˆ˜™†‡‰]*$/;
export function winAnsiSafe(text) { export function winAnsiSafe(text) {
const s = String(text ?? ""); const s = String(text ?? "");
if (WIN_ANSI_OK.test(s)) return { text: s, dropped: 0 }; if (WIN_ANSI_OK.test(s)) return { text: s, dropped: 0 };
let dropped = 0; let dropped = 0;
const out = Array.from(s).map((ch) => { const out = Array.from(s).map((ch) => {
if (WIN_ANSI_OK.test(ch)) return ch; if (WIN_ANSI_OK.test(ch)) return ch;
dropped++; dropped++;
return "?"; return "?";
}).join(""); }).join("");
return { text: out, dropped }; return { text: out, dropped };
} }
/** True when any text stamp carries characters Helvetica cannot write. */ /** True when any text stamp carries characters Helvetica cannot write. */
export function unencodableStamps(model) { export function unencodableStamps(model) {
return model.annots.filter((a) => a.kind === "text" && winAnsiSafe(a.text).dropped > 0).length; return model.annots.filter((a) => a.kind === "text" && winAnsiSafe(a.text).dropped > 0).length;
} }
// ---- mark drawing ----------------------------------------------------- // ---- mark drawing -----------------------------------------------------
function strokePolyline(page, pts, color, width) { function strokePolyline(page, pts, color, width) {
if (pts.length < 2) return; if (pts.length < 2) return;
const ops = [ const ops = [
pushGraphicsState(), pushGraphicsState(),
setStrokingColor(colorOf(color)), setStrokingColor(colorOf(color)),
setLineWidth(width), setLineWidth(width),
setLineCap(LineCapStyle.Round), setLineCap(LineCapStyle.Round),
setLineJoin(LineJoinStyle.Round), setLineJoin(LineJoinStyle.Round),
moveTo(pts[0][0], pts[0][1]), moveTo(pts[0][0], pts[0][1]),
]; ];
for (let i = 1; i < pts.length; i++) ops.push(lineTo(pts[i][0], pts[i][1])); for (let i = 1; i < pts.length; i++) ops.push(lineTo(pts[i][0], pts[i][1]));
ops.push(stroke(), popGraphicsState()); ops.push(stroke(), popGraphicsState());
page.pushOperators(...ops); page.pushOperators(...ops);
} }
function fillRect(page, x, y, w, h, color, opacity) { function fillRect(page, x, y, w, h, color, opacity) {
if (opacity != null && opacity < 1) { if (opacity != null && opacity < 1) {
// drawRectangle is the only path that can set a fill opacity, because it // drawRectangle is the only path that can set a fill opacity, because it
// registers the ExtGState for us. // registers the ExtGState for us.
page.drawRectangle({ x, y, width: w, height: h, color: colorOf(color), opacity, borderWidth: 0 }); page.drawRectangle({ x, y, width: w, height: h, color: colorOf(color), opacity, borderWidth: 0 });
return; return;
} }
page.pushOperators( page.pushOperators(
pushGraphicsState(), setFillingColor(colorOf(color)), pushGraphicsState(), setFillingColor(colorOf(color)),
moveTo(x, y), lineTo(x + w, y), lineTo(x + w, y + h), lineTo(x, y + h), moveTo(x, y), lineTo(x + w, y), lineTo(x + w, y + h), lineTo(x, y + h),
fill(), popGraphicsState()); fill(), popGraphicsState());
} }
function drawMark(page, a, font) { function drawMark(page, a, font) {
switch (a.kind) { switch (a.kind) {
case "highlight": case "highlight":
for (const r of a.rects || []) { for (const r of a.rects || []) {
if (r.w <= 0 || r.h <= 0) continue; if (r.w <= 0 || r.h <= 0) continue;
fillRect(page, r.x, r.y, r.w, r.h, a.color, a.opacity ?? 0.38); fillRect(page, r.x, r.y, r.w, r.h, a.color, a.opacity ?? 0.38);
} }
return; return;
case "pen": case "pen":
strokePolyline(page, a.pts || [], a.color, a.width || 2); strokePolyline(page, a.pts || [], a.color, a.width || 2);
return; return;
case "rect": case "underline": case "strikeout": {
page.drawRectangle({ x: a.x, y: a.y, width: a.w, height: a.h, // Same offset the overlay drew it at, from the same constant.
borderColor: colorOf(a.color), borderWidth: a.width || 2, opacity: 0 }); const frac = RULE_OFFSET[a.kind];
return; const w = Math.max(0.5, a.width || 1.4);
case "ellipse": for (const r of a.rects || []) {
page.drawEllipse({ x: a.x + a.w / 2, y: a.y + a.h / 2, const y = r.y + r.h * frac;
xScale: Math.max(0.5, a.w / 2), yScale: Math.max(0.5, a.h / 2), strokePolyline(page, [[r.x, y], [r.x + r.w, y]], a.color, w);
borderColor: colorOf(a.color), borderWidth: a.width || 2, opacity: 0 }); }
return; return;
case "arrow": { }
const w = a.width || 2; case "rect":
strokePolyline(page, [[a.x1, a.y1], [a.x2, a.y2]], a.color, w); page.drawRectangle({ x: a.x, y: a.y, width: a.w, height: a.h,
const b = arrowBarbs(a.x1, a.y1, a.x2, a.y2, w); borderColor: colorOf(a.color), borderWidth: a.width || 2,
strokePolyline(page, [[b[0].x, b[0].y], [a.x2, a.y2], [b[1].x, b[1].y]], a.color, w); color: a.fill ? colorOf(a.color) : undefined,
return; opacity: a.fill ? (a.opacity ?? 0.25) : 0 });
} return;
case "text": { case "ellipse":
const size = a.size || 12; page.drawEllipse({ x: a.x + a.w / 2, y: a.y + a.h / 2,
for (const line of textLayout(a)) { xScale: Math.max(0.5, a.w / 2), yScale: Math.max(0.5, a.h / 2),
const { text } = winAnsiSafe(line.text); borderColor: colorOf(a.color), borderWidth: a.width || 2,
if (!text) continue; color: a.fill ? colorOf(a.color) : undefined,
page.drawText(text, { x: line.x, y: line.y, size, font, color: colorOf(a.color) }); opacity: a.fill ? (a.opacity ?? 0.25) : 0 });
} return;
return; case "line":
} strokePolyline(page, [[a.x1, a.y1], [a.x2, a.y2]], a.color, a.width || 2);
case "signature": return;
for (const s of signatureStrokes(a)) strokePolyline(page, s, a.color, a.width || 1.6); case "arrow": {
return; const w = a.width || 2;
case "redact": strokePolyline(page, [[a.x1, a.y1], [a.x2, a.y2]], a.color, w);
// Opaque, no border, no transparency — a redaction that can be seen const b = arrowBarbs(a.x1, a.y1, a.x2, a.y2, w);
// through is not a redaction. strokePolyline(page, [[b[0].x, b[0].y], [a.x2, a.y2], [b[1].x, b[1].y]], a.color, w);
fillRect(page, a.x, a.y, a.w, a.h, "#000000", 1); return;
return; }
default: case "text": {
console.warn("save: unknown mark kind", a.kind); const size = a.size || 12;
} const f = typeof font === "function" ? font(a) : font;
} for (const line of textLayout(a)) {
const { text } = winAnsiSafe(line.text);
// ---- form fields ------------------------------------------------------ if (!text) continue;
function applyFormValues(doc, values, font, report) { page.drawText(text, { x: line.x, y: line.y, size, font: f, color: colorOf(a.color) });
if (!values.length) return; }
let form; return;
try { form = doc.getForm(); } }
catch (e) { report.warn.push("This file's form could not be opened, so field values were not saved."); return; } case "signature":
const byName = new Map(); for (const s of signatureStrokes(a)) strokePolyline(page, s, a.color, a.width || 1.6);
try { for (const f of form.getFields()) byName.set(f.getName(), f); } return;
catch (e) { report.warn.push("This file's field list could not be read."); return; } case "redact":
// Opaque, no border, no transparency — a redaction that can be seen
let wrote = 0, missed = 0; // through is not a redaction.
for (const { name, value } of values) { fillRect(page, a.x, a.y, a.w, a.h, "#000000", 1);
const f = byName.get(name); return;
if (!f) { missed++; continue; } default:
try { console.warn("save: unknown mark kind", a.kind);
if (typeof f.setText === "function") { }
const { text, dropped } = winAnsiSafe(value == null ? "" : String(value)); }
if (dropped) report.substituted += dropped;
f.setText(text); // ---- form fields ------------------------------------------------------
} else if (typeof f.check === "function") { function applyFormValues(doc, values, font, report) {
const on = value === true || value === "On" || (typeof value === "string" && value !== "Off" && value !== ""); if (!values.length) return;
if (on) f.check(); else f.uncheck(); let form;
} else if (typeof f.select === "function") { try { form = doc.getForm(); }
if (value == null || value === "") { if (typeof f.clear === "function") f.clear(); } catch (e) { report.warn.push("This file's form could not be opened, so field values were not saved."); return; }
else f.select(Array.isArray(value) ? value[0] : String(value)); const byName = new Map();
} else { missed++; continue; } try { for (const f of form.getFields()) byName.set(f.getName(), f); }
// Per-field so one field Helvetica cannot draw does not abort the save. catch (e) { report.warn.push("This file's field list could not be read."); return; }
try { if (typeof f.updateAppearances === "function") f.updateAppearances(font); } catch {}
wrote++; let wrote = 0, missed = 0;
} catch (e) { for (const { name, value } of values) {
missed++; const f = byName.get(name);
console.warn(`form field "${name}":`, e?.message || e); if (!f) { missed++; continue; }
} try {
} if (typeof f.setText === "function") {
report.fieldsWritten = wrote; const { text, dropped } = winAnsiSafe(value == null ? "" : String(value));
if (missed) report.warn.push(`${missed} field${missed === 1 ? "" : "s"} could not be written back.`); if (dropped) report.substituted += dropped;
} f.setText(text);
} else if (typeof f.check === "function") {
// ---- flattening ------------------------------------------------------- const on = value === true || value === "On" || (typeof value === "string" && value !== "Off" && value !== "");
// A redaction box that only paints over text is theatre: the text is still in if (on) f.check(); else f.uncheck();
// the content stream and still comes out of a copy-paste. The only fix that } else if (typeof f.select === "function") {
// pdf-lib alone can reach is to replace the page's whole content stream with a if (value == null || value === "") { if (typeof f.clear === "function") f.clear(); }
// picture of it, which is what this does. Annotations (form widgets, links) are else f.select(Array.isArray(value) ? value[0] : String(value));
// deliberately KEPT — they are not part of the content stream, so they were } else { missed++; continue; }
// never the leak, and dropping them would quietly break a form. // Per-field so one field Helvetica cannot draw does not abort the save.
async function flattenPage(doc, page, pngBytes, report) { try { if (typeof f.updateAppearances === "function") f.updateAppearances(font); } catch {}
const img = await doc.embedPng(pngBytes); wrote++;
const box = page.getCropBox(); } catch (e) {
// Empty array, not a fresh empty stream: pdf-lib appends its own stream to missed++;
// this array on the first draw call, so the original marks are simply gone. console.warn(`form field "${name}":`, e?.message || e);
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++; report.fieldsWritten = wrote;
} if (missed) report.warn.push(`${missed} field${missed === 1 ? "" : "s"} could not be written back.`);
}
// ---- page tree --------------------------------------------------------
function rebuildPages(doc, originalPages, srcOrder) { // ---- flattening -------------------------------------------------------
const wanted = srcOrder.map((i) => originalPages[i]).filter(Boolean); // A redaction box that only paints over text is theatre: the text is still in
if (!wanted.length) throw new Error("a PDF needs at least one page"); // the content stream and still comes out of a copy-paste. The only fix that
for (let i = doc.getPageCount() - 1; i >= 0; i--) doc.removePage(i); // pdf-lib alone can reach is to replace the page's whole content stream with a
// addPage re-registers a page whose ref was dropped by removePage, so the // picture of it, which is what this does. Annotations (form widgets, links) are
// same page objects (and the widget annotations that point at them) come // deliberately KEPT — they are not part of the content stream, so they were
// back intact in the new order. // never the leak, and dropping them would quietly break a form.
for (const p of wanted) doc.addPage(p); 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
* @param {object} o // this array on the first draw call, so the original marks are simply gone.
* @param {Uint8Array} o.originalBytes untouched source page.node.set(PDFName.of("Contents"), doc.context.obj([]));
* @param {DocModel} o.model page.drawImage(img, { x: box.x, y: box.y, width: box.width, height: box.height });
* @param {{name:string,value:any}[]} o.formValues harvested from pdf.js report.flattened++;
* @param {boolean} o.flatten rebuild redacted pages as images }
* @param {(uid:string)=>Promise<Uint8Array>} o.rasterize renders one page to PNG
* @returns {Promise<{bytes:Uint8Array, report:object}>} // ---- page tree --------------------------------------------------------
*/ function rebuildPages(doc, originalPages, srcOrder) {
export async function buildPdf({ originalBytes, model, formValues = [], flatten = true, rasterize }) { const wanted = srcOrder.map((i) => originalPages[i]).filter(Boolean);
const report = { flattened: 0, marks: 0, fieldsWritten: 0, substituted: 0, warn: [] }; if (!wanted.length) throw new Error("a PDF needs at least one page");
const doc = await PDFDocument.load(originalBytes, { for (let i = doc.getPageCount() - 1; i >= 0; i--) doc.removePage(i);
ignoreEncryption: true, // addPage re-registers a page whose ref was dropped by removePage, so the
// Leave the file's own Producer/ModDate alone. A privacy-minded browser // same page objects (and the widget annotations that point at them) come
// should not stamp "this was edited, with this library, at this time" into // back intact in the new order.
// every document that passes through it. for (const p of wanted) doc.addPage(p);
updateMetadata: false, }
});
const font = await doc.embedFont(StandardFonts.Helvetica); /**
const originalPages = doc.getPages(); * @param {object} o
* @param {Uint8Array} o.originalBytes untouched source
// 1. form values * @param {DocModel} o.model
applyFormValues(doc, formValues, font, report); * @param {{name:string,value:any}[]} o.formValues harvested from pdf.js
* @param {boolean} o.flatten rebuild redacted pages as images
// 2. flatten redacted pages * @param {(uid:string)=>Promise<Uint8Array>} o.rasterize renders one page to PNG
const redacted = model.redactedPages(); * @returns {Promise<{bytes:Uint8Array, report:object}>}
if (flatten && redacted.size && typeof rasterize === "function") { */
for (const uid of redacted) { export async function buildPdf({ originalBytes, model, formValues = [], flatten = true, rasterize }) {
const src = model.page(uid).src; const report = { flattened: 0, marks: 0, fieldsWritten: 0, substituted: 0, warn: [] };
const page = originalPages[src]; const doc = await PDFDocument.load(originalBytes, {
if (!page) continue; ignoreEncryption: true,
try { await flattenPage(doc, page, await rasterize(uid), report); } // Leave the file's own Producer/ModDate alone. A privacy-minded browser
catch (e) { // should not stamp "this was edited, with this library, at this time" into
report.warn.push(`Page ${src + 1} could not be rebuilt as an image; its redaction only covers the text.`); // every document that passes through it.
console.warn("flatten failed:", e); updateMetadata: false,
} });
} // Four variants, because bold and italic are separate fonts in a PDF rather
} else if (redacted.size && !flatten) { // than a style applied to one. Embedded once up front; unused ones cost a
report.warn.push("Redactions cover the text but do not remove it — it is still extractable from the saved file."); // few hundred bytes of font dictionary and nothing else.
} const fonts = {
regular: await doc.embedFont(StandardFonts.Helvetica),
// 3. marks, against original indices bold: await doc.embedFont(StandardFonts.HelveticaBold),
for (const uid of model.visible()) { italic: await doc.embedFont(StandardFonts.HelveticaOblique),
const page = originalPages[model.page(uid).src]; boldItalic: await doc.embedFont(StandardFonts.HelveticaBoldOblique),
if (!page) continue; };
// Redaction boxes go on top of everything else on their page. const font = fonts.regular;
const marks = model.annotsFor(uid); const fontFor = (a) => a.bold && a.italic ? fonts.boldItalic
for (const a of marks) if (a.kind !== "redact") { drawMark(page, a, font); report.marks++; } : a.bold ? fonts.bold
for (const a of marks) if (a.kind === "redact") { drawMark(page, a, font); report.marks++; } : a.italic ? fonts.italic
} : fonts.regular;
const originalPages = doc.getPages();
// 4. page tree
const srcOrder = model.visible().map((uid) => model.page(uid).src); // 1. form values
if (model.pagesChanged()) { applyFormValues(doc, formValues, font, report);
rebuildPages(doc, originalPages, srcOrder);
report.pagesRebuilt = true; // 2. flatten redacted pages
} const redacted = model.redactedPages();
if (flatten && redacted.size && typeof rasterize === "function") {
// 5. rotation — applied to the page objects, so order does not matter for (const uid of redacted) {
for (const uid of model.visible()) { const src = model.page(uid).src;
const extra = model.extraRotation(uid); const page = originalPages[src];
if (!extra) continue; if (!page) continue;
const page = originalPages[model.page(uid).src]; try { await flattenPage(doc, page, await rasterize(uid), report); }
if (!page) continue; catch (e) {
try { page.setRotation(degrees((page.getRotation().angle + extra) % 360)); } report.warn.push(`Page ${src + 1} could not be rebuilt as an image; its redaction only covers the text.`);
catch (e) { console.warn("setRotation:", e?.message); } console.warn("flatten failed:", e);
} }
}
const bytes = await doc.save({ useObjectStreams: true }); } else if (redacted.size && !flatten) {
return { bytes, report }; 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 };
}

View file

@ -1,111 +1,208 @@
// Geometry shared by the on-screen overlay and the pdf-lib writer. // 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 // 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 // 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 // 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 // drew" and "what got saved" the same picture; two copies of it drift the
// first time either side is touched. // first time either side is touched.
// //
// Everything here works in PDF user space: points, origin bottom-left, y up. // Everything here works in PDF user space: points, origin bottom-left, y up.
/** Approximate ascent as a fraction of font size, for Helvetica. */ /** Approximate ascent as a fraction of font size, for Helvetica. */
export const ASCENT = 0.76; export const ASCENT = 0.76;
/** Line advance as a fraction of font size. */ /** Line advance as a fraction of font size. */
export const LINE_GAP = 1.2; export const LINE_GAP = 1.2;
/** /**
* Where each line of a text stamp is drawn. `a.y` is the TOP of the block * 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. * that is what the user clicked so every baseline hangs below it.
* @returns {{text:string, x:number, y:number}[]} y is the baseline. * @returns {{text:string, x:number, y:number}[]} y is the baseline.
*/ */
export function textLayout(a) { export function textLayout(a) {
const lines = String(a.text ?? "").split("\n"); const lines = String(a.text ?? "").split("\n");
const size = a.size || 12; const size = a.size || 12;
return lines.map((text, i) => ({ return lines.map((text, i) => ({
text, text,
x: a.x, x: a.x,
y: a.y - size * ASCENT - i * size * LINE_GAP, y: a.y - size * ASCENT - i * size * LINE_GAP,
})); }));
} }
/** Height of a text stamp's block, for hit-testing and the selection box. */ /** Height of a text stamp's block, for hit-testing and the selection box. */
export function textBlockHeight(a) { export function textBlockHeight(a) {
const lines = String(a.text ?? "").split("\n").length; const lines = String(a.text ?? "").split("\n").length;
const size = a.size || 12; const size = a.size || 12;
return size * ASCENT + (lines - 1) * size * LINE_GAP + size * 0.24; 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). * 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 * Head length scales with stroke width but is floored so a thin arrow still
* reads as an arrow. * reads as an arrow.
*/ */
export function arrowBarbs(x1, y1, x2, y2, width) { export function arrowBarbs(x1, y1, x2, y2, width) {
const dx = x2 - x1, dy = y2 - y1; const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1; const len = Math.hypot(dx, dy) || 1;
const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5)); const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5));
const ux = dx / len, uy = dy / len; const ux = dx / len, uy = dy / len;
// 24° either side of the shaft. // 24° either side of the shaft.
const cos = Math.cos(0.42), sin = Math.sin(0.42); const cos = Math.cos(0.42), sin = Math.sin(0.42);
return [ 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) },
{ 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 * 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 * (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. * dropped at any size, on any page, any number of times.
*/ */
export function signatureStrokes(a) { export function signatureStrokes(a) {
const { x, y, w, h } = a; const { x, y, w, h } = a;
return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h])); 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. */ /** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
export function normBox(x0, y0, x1, y1) { 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) }; 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) { * Where a rule sits inside a line's rectangle, as a fraction of its height
switch (a.kind) { * from the baseline side. Shared by the renderer and the writer so that what
case "highlight": { * is on screen and what lands in the file are the same picture two copies of
const rs = a.rects || []; * this number is two pictures the first time either is touched.
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)); export const RULE_OFFSET = { underline: 0.06, strikeout: 0.42 };
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 }; /** Marks whose geometry is a list of text-line rectangles. */
} export const RECT_LIST_KINDS = new Set(["highlight", "underline", "strikeout"]);
case "pen": { /** Marks whose geometry is a single box. */
const pts = a.pts || []; export const BOX_KINDS = new Set(["rect", "ellipse", "redact", "signature"]);
if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 }; /** Marks defined by two endpoints. */
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]); export const SEGMENT_KINDS = new Set(["arrow", "line"]);
const pad = (a.width || 2) / 2;
return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad, export function boundsOf(a) {
w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 }; if (RECT_LIST_KINDS.has(a.kind)) {
} const rs = a.rects || [];
case "arrow": return normBox(a.x1, a.y1, a.x2, a.y2); if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
case "text": { const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
// Width is unknown without measuring the font; the renderer measures it const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
// and caches it on the mark as `_w` so selection boxes are tight. return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) }; }
} if (SEGMENT_KINDS.has(a.kind)) return normBox(a.x1, a.y1, a.x2, a.y2);
default: return { x: a.x, y: a.y, w: a.w, h: a.h }; switch (a.kind) {
} case "pen": {
} const pts = a.pts || [];
if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */ const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
export function translatePatch(a, dx, dy) { const pad = (a.width || 2) / 2;
switch (a.kind) { return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
case "highlight": w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) }; }
case "pen": case "text": {
return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) }; // Width is unknown without measuring the font; the renderer measures it
case "arrow": // and caches it on the mark as `_w` so selection boxes are tight.
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy }; return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
default: }
return { x: a.x + dx, y: a.y + dy }; 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));
}

View file

@ -1,287 +1,429 @@
// Pointer behaviour for every tool. // Pointer behaviour for every tool.
// //
// The editor is modal: one tool is armed, and what a drag does depends only on // 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 // 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 // what makes PDF annotators infuriating, because a drag meant to draw a box
// selects a paragraph instead. Modality is enforced in CSS (see the // 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 // 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. // stop taking pointer events entirely, so a drag cannot be stolen.
// //
// Highlight is the deliberate exception. It works BY selecting text — the // The text-markup tools are the deliberate exception. Highlight, underline and
// text layer stays live, the browser does the selection, and on release we // strikeout all work BY selecting text — the text layer stays live, the browser
// convert the selection's client rects into user space. Rebuilding text // does the selection, and on release we convert the selection's client rects
// selection from scratch over pdf.js's span soup would be worse at it. // into user space. Rebuilding text selection over pdf.js's span soup would be
// worse at it.
import { boundsOf, normBox, translatePatch } from "./shape.js"; //
// Placing something one-shot — a text stamp, a signature — drops straight back
const MIN_DRAG = 3; // CSS px before a press counts as a drag // to the select tool with the new mark selected. Staying armed means the next
const SIG_WIDTH_PT = 170; // default placed width of a signature // 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.
export class Tools {
constructor({ strip, model, host }) { import { boundsOf, normBox, translatePatch, resizeBox, scaleToBox } from "./shape.js";
this.strip = strip;
this.model = model; const MIN_DRAG = 3; // CSS px before a press counts as a drag
this.host = host; // editor.js: {toast, openTextModal, openSignaturePad, signature(), onChange} const DOUBLE_CLICK_MS = 450; // window for a second click on the same mark
this.tool = "select"; const SIG_WIDTH_PT = 170; // default placed width of a signature
this.color = "#ffd400";
this.width = 2.5; /** Tools that mark up existing text rather than drawing free-hand. */
this.selected = null; export const TEXT_MARKUP = new Set(["highlight", "underline", "strikeout"]);
this.drag = null; /** Tools that place something with a single click. */
// Opening a second document builds a second Tools over the same container. const STAMP_TOOLS = new Set(["text", "signature"]);
// 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 export class Tools {
// goes on one signal and destroy() drops the lot. constructor({ strip, model, host }) {
this._abort = new AbortController(); this.strip = strip;
const opts = { signal: this._abort.signal }; this.model = model;
const c = strip.container; // host (editor.js): { toast, onChange, selectTool, onSelectionChange,
c.addEventListener("pointerdown", (e) => this.onDown(e), opts); // editText, openSignaturePad, signature }
c.addEventListener("pointermove", (e) => this.onMove(e), opts); this.host = host;
window.addEventListener("pointerup", (e) => this.onUp(e), opts); this.tool = "select";
} this.color = "#ffd400";
this.width = 2.5;
destroy() { this.fill = false; // shapes: outline only, or filled
this._abort.abort(); this.fillOpacity = 0.25;
this.drag = null; this.selected = null;
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
get mode() { // at a torn-down strip, and throws on the next click — so every listener
if (this.tool === "select") return "select"; // goes on one signal and destroy() drops the lot.
if (this.tool === "highlight") return "highlight"; this._abort = new AbortController();
if (this.tool === "text" || this.tool === "signature") return "stamp"; const opts = { signal: this._abort.signal };
return "draw"; const c = strip.container;
} c.addEventListener("pointerdown", (e) => this.onDown(e), opts);
c.addEventListener("pointermove", (e) => this.onMove(e), opts);
setTool(t) { window.addEventListener("pointerup", (e) => this.onUp(e), opts);
this.tool = t; }
this.selected = null;
document.body.dataset.mode = this.mode; destroy() {
this.strip.refreshAllOverlays(null); this._abort.abort();
this.host.onChange?.(); this.drag = null;
} this.selected = null;
setColor(c) { this.color = c; if (this.selected) this._recolorSelected(c); } }
setWidth(w) { this.width = w; }
get mode() {
_recolorSelected(c) { if (this.tool === "select") return "select";
const a = this.model.annot(this.selected); if (TEXT_MARKUP.has(this.tool)) return "highlight";
if (!a || a.kind === "redact") return; if (STAMP_TOOLS.has(this.tool)) return "stamp";
this.model.moveAnnot(a.id, { color: c }); return "draw";
this.strip.refreshAllOverlays(this.selected); }
}
setTool(t) {
deleteSelected() { this.tool = t;
if (!this.selected) return false; this._setSelected(null);
const ok = this.model.removeAnnot(this.selected); document.body.dataset.mode = this.mode;
this.selected = null; this.host.onChange?.();
this.strip.refreshAllOverlays(null); }
return ok;
} /** The one place selection changes, so the host never misses one. */
_setSelected(id) {
// ---- pointer ------------------------------------------------------ this.selected = id;
onDown(e) { this.strip.refreshAllOverlays(id);
if (e.button !== 0) return; this.host.onSelectionChange?.(id);
const hit = this.strip.pageAt(e.clientX, e.clientY); }
if (!hit) return; select(id) { this._setSelected(id); }
const ov = this.strip.overlayFor(hit.uid);
if (!ov || !ov.viewport) return; setColor(c) {
this.color = c;
if (this.tool === "highlight") { this._selDirty = false; return; } // let the browser select const a = this.model.annot(this.selected);
if (a && a.kind !== "redact") this.patchSelected({ color: c });
const [x, y] = ov.toPdf(hit.vx, hit.vy); }
setFill(on) {
if (this.tool === "select") { this.fill = !!on;
const id = e.target?.closest?.(".mark")?.dataset?.id || null; const a = this.model.annot(this.selected);
this.selected = id; if (a && (a.kind === "rect" || a.kind === "ellipse")) this.patchSelected({ fill: this.fill });
this.strip.refreshAllOverlays(id); }
if (id) { setWidth(w) {
const a = this.model.annot(id); this.width = w;
this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y, orig: structuredClone(a), moved: false }; const a = this.model.annot(this.selected);
e.preventDefault(); if (a && a.kind !== "text" && a.kind !== "redact") this.patchSelected({ width: w });
} }
return;
} /** Change the selected mark, as one undo step. */
patchSelected(patch) {
if (this.tool === "text") { if (!this.selected) return false;
this.host.openTextModal({ uid: hit.uid, x, y }); const ok = this.model.moveAnnot(this.selected, patch);
e.preventDefault(); this.strip.refreshAllOverlays(this.selected);
return; this.host.onSelectionChange?.(this.selected);
} this.host.onChange?.();
if (this.tool === "signature") { return ok;
this._placeSignature(hit.uid, x, y); }
e.preventDefault();
return; deleteSelected() {
} if (!this.selected) return false;
const ok = this.model.removeAnnot(this.selected);
// Drawing tools: start a drag with a live preview. this._setSelected(null);
this.drag = { kind: this.tool, uid: hit.uid, x0: x, y0: y, x, y, this.host.onChange?.();
pts: this.tool === "pen" ? [[x, y]] : null, return ok;
cx: e.clientX, cy: e.clientY, moved: false }; }
e.preventDefault();
} /** Drop a copy of the selected mark, offset so it is visibly a second one. */
duplicateSelected() {
onMove(e) { const a = this.model.annot(this.selected);
const d = this.drag; if (!a) return null;
if (!d) return; const off = 12;
const ov = this.strip.overlayFor(d.uid); const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
if (!ov || !ov.viewport) return; delete copy.id;
const view = this.strip.views.get(d.uid); delete copy._w;
if (!view) { this.drag = null; return; } const made = this.model.addAnnot(copy);
const rect = view.pv.div.getBoundingClientRect(); this._setSelected(made.id);
// A drag that wanders off the page stays on it. Marks outside the page this.host.onChange?.();
// box are invisible in any PDF reader, so letting one be drawn there return made;
// 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); // ---- pointer ------------------------------------------------------
const [x, y] = ov.toPdf(cx - rect.left, cy - rect.top); onDown(e) {
d.x = x; d.y = y; if (e.button !== 0) return;
if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
// A resize handle is checked before anything else: it sits outside the
if (d.kind === "move") { // mark's own bounds, so the page under it may not even be the page the
const a = this.model.annot(d.id); // mark is on.
if (!a) return; if (this.tool === "select") {
const patch = translatePatch(d.orig, x - d.x0, y - d.y0); const handle = e.target?.closest?.(".handle")?.dataset?.handle;
Object.assign(a, patch); // live, un-journalled; committed on up if (handle && this.selected) {
d.moved = true; const a = this.model.annot(this.selected);
this.strip.refreshOverlay(d.uid, d.id); const ov = a && this.strip.overlayFor(a.page);
return; if (a && ov?.viewport) {
} const view = this.strip.views.get(a.page);
if (d.kind === "pen") { d.pts.push([x, y]); } const r = view.pv.div.getBoundingClientRect();
this.strip.setPreview(d.uid, this._previewAnnot(d)); const [hx, hy] = ov.toPdf(e.clientX - r.left, e.clientY - r.top);
} this.drag = {
kind: "resize", uid: a.page, id: a.id, handle,
onUp(e) { x0: hx, y0: hy, orig: structuredClone(a), from: boundsOf(a),
if (this.tool === "highlight") { this._commitHighlight(); return; } cx: e.clientX, cy: e.clientY, moved: false,
const d = this.drag; };
this.drag = null; e.preventDefault();
if (!d) return; 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; } const hit = this.strip.pageAt(e.clientX, e.clientY);
// Put the mark back where it started, then move it through the model so if (!hit) return;
// the whole gesture lands on the undo stack as one step. const ov = this.strip.overlayFor(hit.uid);
const moved = structuredClone(a); if (!ov || !ov.viewport) return;
Object.assign(a, d.orig);
const patch = {}; if (TEXT_MARKUP.has(this.tool)) return; // let the browser select text
for (const k of Object.keys(moved)) if (k !== "id" && k !== "page" && k !== "kind") patch[k] = moved[k];
this.model.moveAnnot(d.id, patch); const [x, y] = ov.toPdf(hit.vx, hit.vy);
this.strip.refreshAllOverlays(d.id);
return; if (this.tool === "select") {
} const id = e.target?.closest?.(".mark")?.dataset?.id || null;
// Double-click is detected here rather than from a `dblclick` listener.
const a = this._previewAnnot(d, true); // Selecting a mark calls preventDefault() on the pointerdown, and that
if (a) { // suppresses the compatibility mouse events the browser would otherwise
const made = this.model.addAnnot(a); // synthesise a dblclick from — so the native event never arrives.
this.selected = this.tool === "select" ? made.id : null; const now = Date.now();
} const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS;
this.strip.refreshAllOverlays(this.selected); this._lastClick = id ? { id, t: now } : null;
this.host.onChange?.();
} this._setSelected(id);
if (!id) return;
// The mark a drag currently describes. `final` rejects degenerate ones so a e.preventDefault();
// stray click does not leave an invisible zero-size rectangle behind. if (double) {
_previewAnnot(d, final = false) { const a = this.model.annot(id);
const common = { page: d.uid, color: this.color, width: this.width }; if (a?.kind === "text") { this.host.editText?.(id, null); return; }
switch (d.kind) { }
case "pen": { this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y,
if (final && d.pts.length < 2) return null; orig: structuredClone(this.model.annot(id)), cx: e.clientX, cy: e.clientY, moved: false };
return { kind: "pen", ...common, pts: d.pts.slice() }; return;
} }
case "arrow": {
if (final && !d.moved) return null; if (this.tool === "text") {
return { kind: "arrow", ...common, x1: d.x0, y1: d.y0, x2: d.x, y2: d.y }; this.host.editText?.(null, { uid: hit.uid, x, y });
} e.preventDefault();
case "rect": case "ellipse": case "redact": { return;
const b = normBox(d.x0, d.y0, d.x, d.y); }
if (final && (b.w < 2 || b.h < 2)) return null; if (this.tool === "signature") {
if (d.kind === "redact") return { kind: "redact", page: d.uid, ...b }; this._placeSignature(hit.uid, x, y);
return { kind: d.kind, ...common, ...b }; e.preventDefault();
} return;
default: return null; }
}
} // Drawing tools: start a drag with a live preview.
this.drag = { kind: this.tool, uid: hit.uid, x0: x, y0: y, x, y,
// ---- highlight ---------------------------------------------------- pts: this.tool === "pen" ? [[x, y]] : null,
// Take the live selection's client rects, drop them onto whichever page cx: e.clientX, cy: e.clientY, moved: false };
// each one sits on, and convert to user space. A selection spanning a page e.preventDefault();
// break therefore produces one highlight mark per page, which is also what }
// the PDF format wants.
_commitHighlight() { onMove(e) {
const sel = window.getSelection(); const d = this.drag;
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return; if (!d) return;
const byPage = new Map(); const ov = this.strip.overlayFor(d.uid);
for (let i = 0; i < sel.rangeCount; i++) { if (!ov || !ov.viewport) return;
for (const cr of sel.getRangeAt(i).getClientRects()) { const view = this.strip.views.get(d.uid);
if (cr.width < 0.5 || cr.height < 0.5) continue; if (!view) { this.drag = null; return; }
const hit = this.strip.pageAt(cr.left + cr.width / 2, cr.top + cr.height / 2); const rect = view.pv.div.getBoundingClientRect();
if (!hit) continue; // A drag that wanders off the page stays on it. Marks outside the page
const ov = this.strip.overlayFor(hit.uid); // box are invisible in any PDF reader, so letting one be drawn there
if (!ov || !ov.viewport) continue; // would only produce something that disappears on save.
const r = this.strip.views.get(hit.uid).pv.div.getBoundingClientRect(); const cx = Math.min(Math.max(e.clientX, rect.left), rect.right);
const [ax, ay] = ov.toPdf(cr.left - r.left, cr.top - r.top); const cy = Math.min(Math.max(e.clientY, rect.top), rect.bottom);
const [bx, by] = ov.toPdf(cr.right - r.left, cr.bottom - r.top); const [x, y] = ov.toPdf(cx - rect.left, cy - rect.top);
const box = normBox(ax, ay, bx, by); d.x = x; d.y = y;
if (box.w < 0.5 || box.h < 0.5) continue; if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
if (!byPage.has(hit.uid)) byPage.set(hit.uid, []);
byPage.get(hit.uid).push(box); if (d.kind === "move") {
} const a = this.model.annot(d.id);
} if (!a) return;
if (!byPage.size) return; // Live and un-journalled; the whole gesture is committed once on release
for (const [uid, rects] of byPage) { // so undo steps back over the drag, not over each mouse move.
this.model.addAnnot({ kind: "highlight", page: uid, color: this.color, opacity: 0.38, rects: mergeRows(rects) }); Object.assign(a, translatePatch(d.orig, x - d.x0, y - d.y0));
} d.moved = true;
sel.removeAllRanges(); this.strip.refreshOverlay(d.uid, d.id);
this.strip.refreshAllOverlays(null); return;
this.host.onChange?.(); }
} if (d.kind === "resize") {
const a = this.model.annot(d.id);
// ---- stamps ------------------------------------------------------- if (!a) return;
placeText({ uid, x, y }, text, size) { // Always mapped from the ORIGINAL geometry, never from the last frame,
if (!String(text || "").trim()) return null; // so a drag back to where it started restores the mark exactly.
const a = this.model.addAnnot({ kind: "text", page: uid, color: this.color, size, x, y, text }); if (d.handle === "p1" || d.handle === "p2") {
this.strip.refreshAllOverlays(a.id); Object.assign(a, d.handle === "p1" ? { x1: x, y1: y } : { x2: x, y2: y });
this.selected = a.id; } else {
this.host.onChange?.(); const uniform = d.orig.kind === "text" || d.orig.kind === "signature";
return a; const to = resizeBox(d.from, d.handle, x - d.x0, y - d.y0, { uniform });
} Object.assign(a, scaleToBox(d.orig, d.from, to));
}
async _placeSignature(uid, x, y) { d.moved = true;
let sig = this.host.signature(); this.strip.refreshOverlay(d.uid, d.id);
if (!sig) { return;
sig = await this.host.openSignaturePad(); }
if (!sig) return; if (d.kind === "pen") { d.pts.push([x, y]); }
} this.strip.setPreview(d.uid, this._previewAnnot(d));
const w = SIG_WIDTH_PT; }
const h = w * (sig.aspect || 0.34);
const a = this.model.addAnnot({ onUp(e) {
kind: "signature", page: uid, color: this.color === "#ffd400" ? "#1a1f2b" : this.color, if (TEXT_MARKUP.has(this.tool)) { this._commitTextMarkup(); return; }
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes, const d = this.drag;
}); this.drag = null;
this.selected = a.id; if (!d) return;
this.strip.refreshAllOverlays(a.id); this.strip.setPreview(null, null);
this.host.onChange?.();
} if (d.kind === "move" || d.kind === "resize") {
const a = this.model.annot(d.id);
/** Bounds of the current selection, for the status line. */ if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; }
selectedBounds() { this._commitLiveEdit(a, d.orig);
const a = this.model.annot(this.selected); this._setSelected(d.id);
return a ? boundsOf(a) : null; this.host.onChange?.();
} return;
} }
// Client rects for a text selection come one per text run, so a single const a = this._previewAnnot(d, true);
// highlighted line arrives as a dozen slivers. Merging the ones that share a if (a) {
// baseline turns them back into one band per line — fewer objects in the saved const made = this.model.addAnnot(a);
// file, and no seams where two slivers overlap at 38% opacity. this._setSelected(made.id);
function mergeRows(rects) { } else {
const rows = []; this.strip.refreshAllOverlays(this.selected);
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); this.host.onChange?.();
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); // A live drag mutates the mark in place so the screen keeps up. To land it
row.x = x0; row.w = x1 - x0; row.y = y0; row.h = y1 - y0; // on the undo stack as ONE step, put the original back and re-apply the
} // result through the model.
return rows; _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;
}