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",
"name": "PDF Editor",
"version": "0.1.2",
"description": "Open a PDF in a full tab: read it, highlight and mark it up, reorder or rotate or drop pages, fill in its form fields, then save a new copy. Annotations are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"main": "index.js",
"capabilities": ["toolbar-menu", "open-tab", "context-menu-item"],
"toolbar-menu": {
"title": "PDF Editor",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"items": [
{ "id": "open", "label": "Open a PDF…" }
]
},
"context-menu-items": [
{
"id": "open-link",
"label": "Open PDF in editor",
"when": "linkURL"
}
],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pdf-editor/updates.json"
}
{
"id": "pdf-editor",
"name": "PDF Editor",
"version": "0.2.0",
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Marks are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"main": "index.js",
"capabilities": ["toolbar-menu", "open-tab", "context-menu-item"],
"toolbar-menu": {
"title": "PDF Editor",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"items": [
{ "id": "open", "label": "Open a PDF…" }
]
},
"context-menu-items": [
{
"id": "open-link",
"label": "Open PDF in editor",
"when": "linkURL"
}
],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pdf-editor/updates.json"
}

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
a form field stays clickable even with marks drawn around it. */
/* Clipped to the page, because the saved file is too: a PDF viewer shows
nothing outside the crop box, so a mark that spilled past the edge on
screen would simply vanish on save. */
/* Marks are clipped to the page by a clipPath on the marks group, not by the
SVG's own overflow — the selection handles sit just outside a mark's bounds
and a mark flush with the page edge still has to be grabbable. */
.pdfViewer .page .smOverlay { position: absolute; inset: 0; z-index: 3;
pointer-events: none; overflow: hidden; }
pointer-events: none; overflow: visible; }
.pdfViewer .page .annotationLayer { z-index: 4; }
.pdfViewer .page .textLayer { z-index: 2; }
@ -225,3 +225,47 @@ body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all;
.annotationLayer .textWidgetAnnotation input,
.annotationLayer .textWidgetAnnotation textarea,
.annotationLayer .choiceWidgetAnnotation select { font: inherit; }
/* ---- selection bar --------------------------------------------------- */
/* Pinned to the current selection in viewport coordinates, so a page edge
never clips it and moving the selection to another page needs no
re-parenting. It steps aside mid-drag rather than jittering under the
cursor. */
.selbar {
position: fixed; z-index: 40; display: flex; align-items: center; gap: 2px;
padding: 3px; border-radius: 8px; background: var(--panel);
border: 1px solid var(--line); box-shadow: 0 6px 22px var(--shadow);
user-select: none;
}
body[data-dragging="1"] .selbar,
.selbar.offscreen { opacity: 0; pointer-events: none; }
.selbar .sb {
border: 1px solid transparent; background: transparent; color: var(--ink);
min-width: 26px; height: 26px; border-radius: 5px; cursor: pointer; padding: 0 5px;
display: inline-flex; align-items: center; justify-content: center;
font: inherit; font-size: 13px; line-height: 1;
}
.selbar .sb svg { width: 15px; height: 15px; display: block; }
.selbar .sb:hover { background: var(--panel2); border-color: rgb(from var(--acid) r g b / .5); }
.selbar .sb.active { background: rgb(from var(--acid) r g b / .16);
border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
.selbar .sb.danger:hover { border-color: rgba(255,91,91,.6); color: var(--danger); }
.selbar .sb-group { display: inline-flex; align-items: center; gap: 1px; }
.selbar .sb-val { min-width: 22px; text-align: center; font-size: 11.5px;
color: var(--mut); font-variant-numeric: tabular-nums; }
.selbar .sb-sep { width: 1px; height: 17px; background: var(--line); margin: 0 3px; }
/* Style toggles inside the text dialog. */
.btn.styl { width: 30px; font-size: 14px; line-height: 1; }
.btn.styl.active { background: rgb(from var(--acid) r g b / .16);
border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
/* Resize handles. Only grabbable with the select tool armed, so a drawing
drag near a selected mark is never stolen by one. */
body[data-mode="select"] .smOverlay .handle { pointer-events: all; }
.smOverlay .handle { pointer-events: none; }
/* The invisible grab area under a thin stroke. `stroke` rather than
`visibleStroke`: the copy is transparent on purpose. */
body[data-mode="select"] .smOverlay .mark .hit { pointer-events: stroke; }
.smOverlay .mark .hit { pointer-events: none; }

View file

@ -1,216 +1,259 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PDF Editor</title>
<link rel="stylesheet" href="vendor/pdfjs/web/pdf_viewer.css">
<link rel="stylesheet" href="editor.css">
</head>
<body data-empty="1">
<!-- Top bar: document identity on the left, destructive/close actions right.
Same split as the screenshot editor's topbar. -->
<div class="topbar">
<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>
<span>Open</span>
</button>
<span class="sep"></span>
<span class="docname" id="docname">No document</span>
<span class="spacer"></span>
<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>
<span>Save a copy</span>
</button>
<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>
</button>
</div>
<!-- Tool bar. `.tool-cluster` centres the drawing groups so the view
controls can right-anchor without the tools crowding the left edge. -->
<div class="toolbar" id="toolbar">
<div class="tool-cluster">
<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)">
<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 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>
</button>
<button class="tool" data-tool="pen" title="Pen — freehand (P)">
<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 class="tool" data-tool="rect" title="Rectangle (R)">
<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 class="tool" data-tool="ellipse" title="Ellipse (O)">
<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 class="tool" data-tool="arrow" title="Arrow (A)">
<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 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>
</button>
<button class="tool" data-tool="signature" title="Signature stamp (G)">
<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>
<button class="tool" data-tool="redact" title="Redact — drag a box over content to remove it" data-warn="1">
<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>
</button>
</div>
<div class="tgroup" role="group" aria-label="Colours">
<button class="swatch active" data-color="#ffd400" style="background:#ffd400" title="Yellow"></button>
<button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
<button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
<button class="swatch" data-color="#2bd97c" style="background:#2bd97c" title="Green"></button>
<button class="swatch" data-color="#1a1f2b" style="background:#1a1f2b" title="Ink"></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>
<button class="width active" data-width="2.5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
<button class="width" data-width="4.5" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
</div>
<div class="tgroup" role="group" aria-label="History">
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
<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>
<button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<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>
</div>
</div>
<div class="tgroup zoomgroup" role="group" aria-label="Zoom">
<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>
</button>
<select id="zoom" title="Zoom">
<option value="page-width">Fit width</option>
<option value="page-fit">Fit page</option>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1">100%</option>
<option value="1.25">125%</option>
<option value="1.5">150%</option>
<option value="2">200%</option>
<option value="4">400%</option>
</select>
<button class="btn" id="zoom-in" title="Zoom in (+)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
</button>
<button class="btn" id="toggle-rail" title="Show or hide the page thumbnails">
<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>
</button>
</div>
</div>
<div class="body">
<!-- Scroll container. pdf.js's own CSS keys off #viewer .page, and reads
--scale-factor from the container, so the structure below mirrors what
the pdf.js viewer builds. -->
<div class="viewerContainer" id="viewerContainer">
<div id="viewer" class="pdfViewer"></div>
<div class="dropzone" id="dropzone">
<div class="dz-inner">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 6h16l8 8v28H12z"/><path d="M28 6v8h8"/><path d="M24 22v12M19 29l5 5 5-5"/>
</svg>
<div class="dz-title">Drop a PDF here</div>
<div class="dz-sub">or <button class="linkish" id="open2">choose a file</button></div>
</div>
</div>
</div>
<!-- Page rail. Thumbnails are draggable to reorder; each carries rotate
and delete buttons. -->
<aside class="rail" id="rail">
<div class="rail-head">
<span id="rail-count">0 pages</span>
<button class="fbtn" id="restore-pages" hidden title="Bring back every deleted page">Undo deletes</button>
</div>
<div class="rail-list" id="rail-list"></div>
</aside>
</div>
<div class="footer">
<span class="fstat" id="status">Ready</span>
<span class="spacer"></span>
<span class="fstat" id="pageinfo"></span>
</div>
<!-- Modal shell, reused by the redaction warning, the signature pad and the
text-stamp prompt. One at a time; `data-modal` names the open one. -->
<div class="scrim" id="scrim" hidden>
<div class="modal" id="modal-redact" hidden>
<h2>About redaction</h2>
<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>
<p>To actually remove it, this editor rebuilds each redacted page as an image when you save.
That page's text stops being selectable and any form fields on it become static. Pages you
did not redact are untouched.</p>
<label class="check"><input type="checkbox" id="redact-flatten" checked>
<span>Rebuild redacted pages as images when saving (recommended)</span></label>
<div class="mrow">
<button class="btn wide" id="redact-cancel">Cancel</button>
<button class="btn wide primary" id="redact-ok">Use the redaction tool</button>
</div>
</div>
<div class="modal" id="modal-sign" hidden>
<h2>Draw your signature</h2>
<p class="mhint">Draw in the box, then place it on the page. It is stored as lines, not a
picture, so it stays sharp at any zoom.</p>
<canvas id="sign-pad" width="640" height="220"></canvas>
<div class="mrow">
<button class="btn wide" id="sign-clear">Clear</button>
<span class="spacer"></span>
<button class="btn wide" id="sign-cancel">Cancel</button>
<button class="btn wide primary" id="sign-ok" disabled>Use this signature</button>
</div>
</div>
<div class="modal" id="modal-text" hidden>
<h2>Text stamp</h2>
<textarea id="text-body" rows="4" placeholder="Type the text to place…"></textarea>
<div class="mrow">
<label class="mfield">Size
<select id="text-size">
<option value="8">8</option><option value="10">10</option>
<option value="12" selected>12</option><option value="14">14</option>
<option value="18">18</option><option value="24">24</option><option value="36">36</option>
</select>
</label>
<span class="spacer"></span>
<button class="btn wide" id="text-cancel">Cancel</button>
<button class="btn wide primary" id="text-ok">Place</button>
</div>
<p class="mhint" id="text-warn" hidden></p>
</div>
<div class="modal" id="modal-save" hidden>
<h2>Save a copy</h2>
<label class="mfield block">File name
<input type="text" id="save-name" spellcheck="false">
</label>
<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>
<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>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PDF Editor</title>
<link rel="stylesheet" href="vendor/pdfjs/web/pdf_viewer.css">
<link rel="stylesheet" href="editor.css">
</head>
<body data-empty="1">
<!-- Top bar: document identity on the left, destructive/close actions right.
Same split as the screenshot editor's topbar. -->
<div class="topbar">
<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>
<span>Open</span>
</button>
<span class="sep"></span>
<span class="docname" id="docname">No document</span>
<span class="spacer"></span>
<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>
<span>Save a copy</span>
</button>
<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>
</button>
</div>
<!-- Tool bar. `.tool-cluster` centres the drawing groups so the view
controls can right-anchor without the tools crowding the left edge. -->
<div class="toolbar" id="toolbar">
<div class="tool-cluster">
<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)">
<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 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>
</button>
<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.6" stroke-linecap="round"><path d="M4 2v5a4 4 0 0 0 8 0V2"/><path d="M3 14h10"/></svg>
</button>
<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.6" stroke-linecap="round"><path d="M4 3.5h8"/><path d="M8 3.5V12"/><path d="M2.5 8h11"/></svg>
</button>
<button class="tool" data-tool="pen" title="Pen — freehand (P)">
<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 class="tool" data-tool="rect" title="Rectangle (R)">
<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 class="tool" data-tool="ellipse" title="Ellipse (O)">
<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 class="tool" data-tool="line" title="Line (L)">
<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 class="tool" data-tool="arrow" title="Arrow (A)">
<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 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>
</button>
<button class="tool" data-tool="signature" title="Signature stamp (G)">
<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>
<button class="tool" data-tool="redact" title="Redact — drag a box over content to remove it" data-warn="1">
<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>
</button>
</div>
<div class="tgroup" role="group" aria-label="Colours">
<button class="swatch active" data-color="#ffd400" style="background:#ffd400" title="Yellow"></button>
<button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
<button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
<button class="swatch" data-color="#2bd97c" style="background:#2bd97c" title="Green"></button>
<button class="swatch" data-color="#1a1f2b" style="background:#1a1f2b" title="Ink"></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>
<button class="width active" data-width="2.5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
<button class="width" data-width="4.5" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
<button class="btn" id="toggle-fill" title="Fill shapes as well as outline them">
<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>
<div class="tgroup" role="group" aria-label="History">
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
<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>
<button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<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>
</div>
</div>
<div class="tgroup zoomgroup" role="group" aria-label="Zoom">
<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>
</button>
<select id="zoom" title="Zoom">
<option value="page-width">Fit width</option>
<option value="page-fit">Fit page</option>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1">100%</option>
<option value="1.25">125%</option>
<option value="1.5">150%</option>
<option value="2">200%</option>
<option value="4">400%</option>
</select>
<button class="btn" id="zoom-in" title="Zoom in (+)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
</button>
<button class="btn" id="toggle-rail" title="Show or hide the page thumbnails">
<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>
</button>
</div>
</div>
<div class="body">
<!-- Scroll container. pdf.js's own CSS keys off #viewer .page, and reads
--scale-factor from the container, so the structure below mirrors what
the pdf.js viewer builds. -->
<div class="viewerContainer" id="viewerContainer">
<div id="viewer" class="pdfViewer"></div>
<div class="dropzone" id="dropzone">
<div class="dz-inner">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 6h16l8 8v28H12z"/><path d="M28 6v8h8"/><path d="M24 22v12M19 29l5 5 5-5"/>
</svg>
<div class="dz-title">Drop a PDF here</div>
<div class="dz-sub">or <button class="linkish" id="open2">choose a file</button></div>
</div>
</div>
</div>
<!-- Page rail. Thumbnails are draggable to reorder; each carries rotate
and delete buttons. -->
<aside class="rail" id="rail">
<div class="rail-head">
<span id="rail-count">0 pages</span>
<button class="fbtn" id="restore-pages" hidden title="Bring back every deleted page">Undo deletes</button>
</div>
<div class="rail-list" id="rail-list"></div>
</aside>
</div>
<div class="footer">
<span class="fstat" id="status">Ready</span>
<span class="spacer"></span>
<span class="fstat" id="pageinfo"></span>
</div>
<!-- Modal shell, reused by the redaction warning, the signature pad and the
text-stamp prompt. One at a time; `data-modal` names the open one. -->
<div class="scrim" id="scrim" hidden>
<div class="modal" id="modal-redact" hidden>
<h2>About redaction</h2>
<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>
<p>To actually remove it, this editor rebuilds each redacted page as an image when you save.
That page's text stops being selectable and any form fields on it become static. Pages you
did not redact are untouched.</p>
<label class="check"><input type="checkbox" id="redact-flatten" checked>
<span>Rebuild redacted pages as images when saving (recommended)</span></label>
<div class="mrow">
<button class="btn wide" id="redact-cancel">Cancel</button>
<button class="btn wide primary" id="redact-ok">Use the redaction tool</button>
</div>
</div>
<div class="modal" id="modal-sign" hidden>
<h2>Draw your signature</h2>
<p class="mhint">Draw in the box, then place it on the page. It is stored as lines, not a
picture, so it stays sharp at any zoom.</p>
<canvas id="sign-pad" width="640" height="220"></canvas>
<div class="mrow">
<button class="btn wide" id="sign-clear">Clear</button>
<span class="spacer"></span>
<button class="btn wide" id="sign-cancel">Cancel</button>
<button class="btn wide primary" id="sign-ok" disabled>Use this signature</button>
</div>
</div>
<div class="modal" id="modal-text" hidden>
<h2 id="modal-text-title">Text stamp</h2>
<textarea id="text-body" rows="4" placeholder="Type the text to place…"></textarea>
<div class="mrow">
<label class="mfield">Size
<select id="text-size">
<option value="8">8</option><option value="10">10</option>
<option value="12" selected>12</option><option value="14">14</option>
<option value="18">18</option><option value="24">24</option>
<option value="36">36</option><option value="48">48</option><option value="72">72</option>
</select>
</label>
<button class="btn styl" id="text-bold" type="button" title="Bold"><b>B</b></button>
<button class="btn styl" id="text-italic" type="button" title="Italic"><i>I</i></button>
<span class="spacer"></span>
<button class="btn wide" id="text-cancel">Cancel</button>
<button class="btn wide primary" id="text-ok">Place</button>
</div>
<p class="mhint" id="text-warn" hidden></p>
</div>
<div class="modal" id="modal-save" hidden>
<h2>Save a copy</h2>
<label class="mfield block">File name
<input type="text" id="save-name" spellcheck="false">
</label>
<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.
//
// One SVG per page, sized to that page's current viewport in CSS pixels.
// Every mark is stored in PDF user space, so drawing means running each point
// through `viewport.convertToViewportPoint` — which already accounts for the
// page's scale AND its rotation. That is why rotating a page needs no special
// handling here: the viewport changes, we re-render, and the marks turn with
// the content they were drawn on.
//
// SVG rather than a canvas because marks have to be individually
// hit-testable, selectable and movable, and because it stays crisp when the
// user zooms without us re-rasterising anything.
import { arrowBarbs, boundsOf, signatureStrokes, textLayout } from "./shape.js";
const NS = "http://www.w3.org/2000/svg";
function el(name, attrs) {
const n = document.createElementNS(NS, name);
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
return n;
}
export class Overlay {
/** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
constructor(pageDiv) {
this.svg = el("svg", { class: "smOverlay", xmlns: NS });
this.marks = el("g", { class: "marks" });
this.chrome = el("g", { class: "chrome" }); // selection box + handles
this.svg.append(this.marks, this.chrome);
pageDiv.append(this.svg);
this.viewport = null;
}
destroy() { this.svg.remove(); }
/** Map a user-space point to CSS pixels inside the page div. */
toView(x, y) {
const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
return [vx, vy];
}
/** Map a page-relative CSS pixel point back to user space. */
toPdf(vx, vy) {
const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
return [x, y];
}
render(viewport, annots, selectedId) {
this.viewport = viewport;
this.svg.setAttribute("width", viewport.width);
this.svg.setAttribute("height", viewport.height);
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
this.marks.textContent = "";
for (const a of annots) {
const node = this._draw(a);
if (!node) continue;
node.classList.add("mark");
node.dataset.id = a.id;
if (a.id === selectedId) node.classList.add("sel");
this.marks.append(node);
}
this._drawChrome(annots.find((a) => a.id === selectedId) || null);
}
// Scale a user-space length (a stroke width, a font size) into CSS pixels.
// The viewport's scale already carries zoom; rotation does not change length.
get k() { return this.viewport ? this.viewport.scale : 1; }
_draw(a) {
const g = el("g", {});
switch (a.kind) {
case "highlight": {
for (const r of a.rects || []) {
const [x0, y1] = this.toView(r.x, r.y + r.h);
const [x1, y0] = this.toView(r.x + r.w, r.y);
g.append(el("rect", {
x: Math.min(x0, x1), y: Math.min(y0, y1),
width: Math.abs(x1 - x0), height: Math.abs(y0 - y1),
fill: a.color, "fill-opacity": a.opacity ?? 0.38,
style: "mix-blend-mode:multiply",
}));
}
g.dataset.fill = "1";
return g;
}
case "pen": {
const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
if (pts.length < 2) return null;
g.append(el("polyline", {
points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "rect": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k }));
return g;
}
case "ellipse": {
const b = this._box(a);
g.append(el("ellipse", {
cx: b.x + b.width / 2, cy: b.y + b.height / 2,
rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
}));
return g;
}
case "arrow": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
const w = (a.width || 2) * this.k;
const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y));
g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" }));
g.append(el("polyline", {
points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "text": {
const size = (a.size || 12) * this.k;
let widest = 0;
for (const line of textLayout(a)) {
const [vx, vy] = this.toView(line.x, line.y);
const t = el("text", {
x: vx, y: vy, fill: a.color,
"font-family": "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
// Measure once attached so the selection box is tight. Cached on the
// mark in user-space units; boundsOf() reads it.
requestAnimationFrame(() => {
try {
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
if (widest > 0) a._w = widest / this.k;
} catch {}
});
return g;
}
case "signature": {
const w = (a.width || 1.6) * this.k;
for (const stroke of signatureStrokes(a)) {
if (stroke.length < 2) continue;
g.append(el("polyline", {
points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "),
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
}
return g;
}
case "redact": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: "#000" }));
g.dataset.fill = "1";
return g;
}
default: return null;
}
}
// A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
// corners so a rotated viewport lands the right way up.
_box(a) {
const [ax, ay] = this.toView(a.x, a.y);
const [bx, by] = this.toView(a.x + a.w, a.y + a.h);
return { x: Math.min(ax, bx), y: Math.min(ay, by),
width: Math.abs(bx - ax), height: Math.abs(by - ay) };
}
_drawChrome(sel) {
this.chrome.textContent = "";
if (!sel) return;
const b = boundsOf(sel);
const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h });
const pad = 3;
this.chrome.append(el("rect", {
class: "selbox", x: r.x - pad, y: r.y - pad,
width: r.width + pad * 2, height: r.height + pad * 2,
}));
}
/** The mark id under a page-relative CSS pixel point, or null. */
hitTest(vx, vy) {
const node = document.elementFromPoint(
vx + this.svg.getBoundingClientRect().left,
vy + this.svg.getBoundingClientRect().top);
const mark = node?.closest?.(".mark");
return mark ? mark.dataset.id : null;
}
}
// The on-screen layer that draws marks over a rendered page.
//
// One SVG per page, sized to that page's current viewport in CSS pixels.
// Every mark is stored in PDF user space, so drawing means running each point
// through `viewport.convertToViewportPoint` — which already accounts for the
// page's scale AND its rotation. That is why rotating a page needs no special
// handling here: the viewport changes, we re-render, and the marks turn with
// the content they were drawn on.
//
// SVG rather than a canvas because marks have to be individually
// hit-testable, selectable and movable, and because it stays crisp when the
// user zooms without us re-rasterising anything.
import { arrowBarbs, boundsOf, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
const NS = "http://www.w3.org/2000/svg";
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
let clipSeq = 0; // clipPath ids must be unique across every page in the document
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
function el(name, attrs) {
const n = document.createElementNS(NS, name);
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
return n;
}
export class Overlay {
/** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
constructor(pageDiv) {
this.svg = el("svg", { class: "smOverlay", xmlns: NS });
// Marks are clipped to the page, because a PDF reader shows nothing
// outside the crop box and a mark that spilled over the edge on screen
// would vanish on save. The selection chrome is NOT clipped: a mark
// flush against the edge still needs grabbable handles, and those sit
// just outside its bounds.
this.clipId = `smclip-${++clipSeq}`;
const defs = el("defs", {});
this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 });
const clip = el("clipPath", { id: this.clipId });
clip.append(this.clipRect);
defs.append(clip);
this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` });
this.chrome = el("g", { class: "chrome" }); // selection box + handles
this.svg.append(defs, this.marks, this.chrome);
pageDiv.append(this.svg);
this.viewport = null;
}
destroy() { this.svg.remove(); }
/** Map a user-space point to CSS pixels inside the page div. */
toView(x, y) {
const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
return [vx, vy];
}
/** Map a page-relative CSS pixel point back to user space. */
toPdf(vx, vy) {
const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
return [x, y];
}
render(viewport, annots, selectedId) {
this.viewport = viewport;
this._selId = selectedId;
this.svg.setAttribute("width", viewport.width);
this.svg.setAttribute("height", viewport.height);
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
this.clipRect.setAttribute("width", viewport.width);
this.clipRect.setAttribute("height", viewport.height);
this.marks.textContent = "";
for (const a of annots) {
const node = this._draw(a);
if (!node) continue;
this._addHitArea(node, a);
node.classList.add("mark");
node.dataset.id = a.id;
if (a.id === selectedId) node.classList.add("sel");
this.marks.append(node);
}
this._drawChrome(annots.find((a) => a.id === selectedId) || null);
}
// Scale a user-space length (a stroke width, a font size) into CSS pixels.
// The viewport's scale already carries zoom; rotation does not change length.
get k() { return this.viewport ? this.viewport.scale : 1; }
_draw(a) {
const g = el("g", {});
switch (a.kind) {
case "highlight": {
for (const r of a.rects || []) {
const b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h });
g.append(el("rect", {
...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38,
style: "mix-blend-mode:multiply",
}));
}
g.dataset.fill = "1";
return g;
}
// Underline and strikeout share the highlight's geometry — one rect per
// line of selected text — and differ only in where the rule sits inside
// that rect. Keeping them on the same shape means a selection spanning a
// page break splits the same way for all three.
case "underline": case "strikeout": {
const frac = RULE_OFFSET[a.kind];
for (const r of a.rects || []) {
const yy = r.y + r.h * frac;
const [x1, y1] = this.toView(r.x, yy);
const [x2, y2] = this.toView(r.x + r.w, yy);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt",
}));
}
return g;
}
case "pen": {
const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
if (pts.length < 2) return null;
g.append(el("polyline", {
points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "rect": {
const b = this._box(a);
g.append(el("rect", { ...b, ...this._paint(a) }));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "ellipse": {
const b = this._box(a);
g.append(el("ellipse", {
cx: b.x + b.width / 2, cy: b.y + b.height / 2,
rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
...this._paint(a),
}));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "line": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round",
}));
return g;
}
case "arrow": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
const w = (a.width || 2) * this.k;
const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y));
g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" }));
g.append(el("polyline", {
points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "text": {
const size = (a.size || 12) * this.k;
let widest = 0;
for (const line of textLayout(a)) {
const [vx, vy] = this.toView(line.x, line.y);
const t = el("text", {
x: vx, y: vy, fill: a.color,
"font-family": "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
// A string's width is only knowable once the browser has laid it out,
// so it is measured a frame later and cached on the mark in user-space
// units for boundsOf(). The selection box is drawn from that, and was
// drawn before the measurement existed — so redraw it once the real
// width is in, or the box and its handles sit at the fallback guess.
requestAnimationFrame(() => {
try {
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
if (widest <= 0) return;
const w = widest / this.k;
const changed = Math.abs((a._w ?? 0) - w) > 0.5;
a._w = w;
if (changed && a.id === this._selId) this._drawChrome(a);
} catch {}
});
return g;
}
case "signature": {
const w = (a.width || 1.6) * this.k;
for (const stroke of signatureStrokes(a)) {
if (stroke.length < 2) continue;
g.append(el("polyline", {
points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "),
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
}
return g;
}
case "redact": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: "#000" }));
g.dataset.fill = "1";
return g;
}
default: return null;
}
}
/**
* Give a mark something to grab.
*
* A 2 pt outline is about one screen pixel: selecting it means hitting a
* hairline exactly, which is miserable with a mouse and impossible with a
* trackpad. Each stroked shape gets an invisible copy of itself with a fat
* stroke underneath, purely to catch the pointer. `pointer-events: stroke`
* hit-tests the stroke area whatever the paint is, so a transparent one
* still counts.
*/
_addHitArea(g, a) {
if (g.dataset.fill === "1") return; // already a solid target
const wide = Math.max(HIT_TOLERANCE, (a.width || 2) * this.k * 1.8);
const clones = [];
for (const child of g.children) {
if (child.tagName === "text") continue;
const c = child.cloneNode(false);
c.setAttribute("stroke", "transparent");
c.setAttribute("stroke-width", wide);
c.setAttribute("fill", "none");
c.removeAttribute("style");
c.setAttribute("class", "hit");
clones.push(c);
}
for (const c of clones) g.insertBefore(c, g.firstChild);
}
// A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
// corners so a rotated viewport lands the right way up.
_box(a) {
const [ax, ay] = this.toView(a.x, a.y);
const [bx, by] = this.toView(a.x + a.w, a.y + a.h);
return { x: Math.min(ax, bx), y: Math.min(ay, by),
width: Math.abs(bx - ax), height: Math.abs(by - ay) };
}
// The selection outline and its grab handles.
//
// Handles are drawn at a fixed pixel size rather than scaled with the page,
// so they stay grabbable at 50% zoom and do not swell into the artwork at
// 400%. Each carries data-handle, which is what tools.js hit-tests to tell a
// resize from a move.
_drawChrome(sel) {
this.chrome.textContent = "";
if (!sel) return;
const b = boundsOf(sel);
const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h });
const pad = 3;
this.chrome.append(el("rect", {
class: "selbox", x: r.x - pad, y: r.y - pad,
width: r.width + pad * 2, height: r.height + pad * 2,
}));
const names = handlesFor(sel);
if (SEGMENT_KINDS.has(sel.kind)) {
// A line's ends are the only meaningful grips; a bounding box would let
// you stretch it in ways that never match what you were aiming at.
const ends = { p1: this.toView(sel.x1, sel.y1), p2: this.toView(sel.x2, sel.y2) };
for (const n of names) this._handle(n, ends[n][0], ends[n][1], "move");
return;
}
const L = r.x - pad, R = r.x + r.width + pad;
const T = r.y - pad, B = r.y + r.height + pad;
const MX = (L + R) / 2, MY = (T + B) / 2;
const at = { nw: [L, T], n: [MX, T], ne: [R, T], e: [R, MY],
se: [R, B], s: [MX, B], sw: [L, B], w: [L, MY] };
const cursor = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize",
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
for (const n of names) {
const p = at[n];
if (p) this._handle(n, p[0], p[1], cursor[n]);
}
}
_handle(name, cx, cy, cursor) {
const h = el("rect", {
class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2,
width: HANDLE, height: HANDLE, rx: 1.5,
style: `cursor:${cursor}`,
});
h.dataset.handle = name;
this.chrome.append(h);
}
// Stroke and fill for a shape, honouring its fill and opacity.
_paint(a) {
return {
fill: a.fill ? a.color : "none",
"fill-opacity": a.fill ? (a.opacity ?? 0.25) : 0,
stroke: a.color,
"stroke-width": (a.width || 2) * this.k,
};
}
/** The mark id under a page-relative CSS pixel point, or null. */
hitTest(vx, vy) {
const node = document.elementFromPoint(
vx + this.svg.getBoundingClientRect().left,
vy + this.svg.getBoundingClientRect().top);
const mark = node?.closest?.(".mark");
return mark ? mark.dataset.id : null;
}
}

View file

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

View file

@ -1,111 +1,208 @@
// Geometry shared by the on-screen overlay and the pdf-lib writer.
//
// Both need to agree exactly on where an arrowhead's barbs land, where each
// line of a text stamp sits, and how a normalised signature stroke maps into
// its placed box. Keeping that arithmetic in one place is what makes "what I
// drew" and "what got saved" the same picture; two copies of it drift the
// first time either side is touched.
//
// Everything here works in PDF user space: points, origin bottom-left, y up.
/** Approximate ascent as a fraction of font size, for Helvetica. */
export const ASCENT = 0.76;
/** Line advance as a fraction of font size. */
export const LINE_GAP = 1.2;
/**
* Where each line of a text stamp is drawn. `a.y` is the TOP of the block
* that is what the user clicked so every baseline hangs below it.
* @returns {{text:string, x:number, y:number}[]} y is the baseline.
*/
export function textLayout(a) {
const lines = String(a.text ?? "").split("\n");
const size = a.size || 12;
return lines.map((text, i) => ({
text,
x: a.x,
y: a.y - size * ASCENT - i * size * LINE_GAP,
}));
}
/** Height of a text stamp's block, for hit-testing and the selection box. */
export function textBlockHeight(a) {
const lines = String(a.text ?? "").split("\n").length;
const size = a.size || 12;
return size * ASCENT + (lines - 1) * size * LINE_GAP + size * 0.24;
}
/**
* The two barb points of an arrowhead at (x2,y2) coming from (x1,y1).
* Head length scales with stroke width but is floored so a thin arrow still
* reads as an arrow.
*/
export function arrowBarbs(x1, y1, x2, y2, width) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5));
const ux = dx / len, uy = dy / len;
// 24° either side of the shaft.
const cos = Math.cos(0.42), sin = Math.sin(0.42);
return [
{ x: x2 - head * (ux * cos - uy * sin), y: y2 - head * (uy * cos + ux * sin) },
{ x: x2 - head * (ux * cos + uy * sin), y: y2 - head * (uy * cos - ux * sin) },
];
}
/**
* A placed signature's strokes in user space. Strokes are stored normalised
* (0..1, y measured DOWN from the top of the pad) so the same drawing can be
* dropped at any size, on any page, any number of times.
*/
export function signatureStrokes(a) {
const { x, y, w, h } = a;
return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
}
/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
export function normBox(x0, y0, x1, y1) {
return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
}
/** Axis-aligned bounds of a mark, in user space. Used for selection and hit-testing. */
export function boundsOf(a) {
switch (a.kind) {
case "highlight": {
const rs = a.rects || [];
if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
case "pen": {
const pts = a.pts || [];
if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
const pad = (a.width || 2) / 2;
return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
}
case "arrow": return normBox(a.x1, a.y1, a.x2, a.y2);
case "text": {
// Width is unknown without measuring the font; the renderer measures it
// and caches it on the mark as `_w` so selection boxes are tight.
return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
}
default: return { x: a.x, y: a.y, w: a.w, h: a.h };
}
}
/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */
export function translatePatch(a, dx, dy) {
switch (a.kind) {
case "highlight":
return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
case "pen":
return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
case "arrow":
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
default:
return { x: a.x + dx, y: a.y + dy };
}
}
// Geometry shared by the on-screen overlay and the pdf-lib writer.
//
// Both need to agree exactly on where an arrowhead's barbs land, where each
// line of a text stamp sits, and how a normalised signature stroke maps into
// its placed box. Keeping that arithmetic in one place is what makes "what I
// drew" and "what got saved" the same picture; two copies of it drift the
// first time either side is touched.
//
// Everything here works in PDF user space: points, origin bottom-left, y up.
/** Approximate ascent as a fraction of font size, for Helvetica. */
export const ASCENT = 0.76;
/** Line advance as a fraction of font size. */
export const LINE_GAP = 1.2;
/**
* Where each line of a text stamp is drawn. `a.y` is the TOP of the block
* that is what the user clicked so every baseline hangs below it.
* @returns {{text:string, x:number, y:number}[]} y is the baseline.
*/
export function textLayout(a) {
const lines = String(a.text ?? "").split("\n");
const size = a.size || 12;
return lines.map((text, i) => ({
text,
x: a.x,
y: a.y - size * ASCENT - i * size * LINE_GAP,
}));
}
/** Height of a text stamp's block, for hit-testing and the selection box. */
export function textBlockHeight(a) {
const lines = String(a.text ?? "").split("\n").length;
const size = a.size || 12;
return size * ASCENT + (lines - 1) * size * LINE_GAP + size * 0.24;
}
/**
* The two barb points of an arrowhead at (x2,y2) coming from (x1,y1).
* Head length scales with stroke width but is floored so a thin arrow still
* reads as an arrow.
*/
export function arrowBarbs(x1, y1, x2, y2, width) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5));
const ux = dx / len, uy = dy / len;
// 24° either side of the shaft.
const cos = Math.cos(0.42), sin = Math.sin(0.42);
return [
{ x: x2 - head * (ux * cos - uy * sin), y: y2 - head * (uy * cos + ux * sin) },
{ x: x2 - head * (ux * cos + uy * sin), y: y2 - head * (uy * cos - ux * sin) },
];
}
/**
* A placed signature's strokes in user space. Strokes are stored normalised
* (0..1, y measured DOWN from the top of the pad) so the same drawing can be
* dropped at any size, on any page, any number of times.
*/
export function signatureStrokes(a) {
const { x, y, w, h } = a;
return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
}
/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
export function normBox(x0, y0, x1, y1) {
return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
}
/**
* Where a rule sits inside a line's rectangle, as a fraction of its height
* from the baseline side. Shared by the renderer and the writer so that what
* is on screen and what lands in the file are the same picture two copies of
* this number is two pictures the first time either is touched.
*/
export const RULE_OFFSET = { underline: 0.06, strikeout: 0.42 };
/** Marks whose geometry is a list of text-line rectangles. */
export const RECT_LIST_KINDS = new Set(["highlight", "underline", "strikeout"]);
/** Marks whose geometry is a single box. */
export const BOX_KINDS = new Set(["rect", "ellipse", "redact", "signature"]);
/** Marks defined by two endpoints. */
export const SEGMENT_KINDS = new Set(["arrow", "line"]);
export function boundsOf(a) {
if (RECT_LIST_KINDS.has(a.kind)) {
const rs = a.rects || [];
if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
if (SEGMENT_KINDS.has(a.kind)) return normBox(a.x1, a.y1, a.x2, a.y2);
switch (a.kind) {
case "pen": {
const pts = a.pts || [];
if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
const pad = (a.width || 2) / 2;
return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
}
case "text": {
// Width is unknown without measuring the font; the renderer measures it
// and caches it on the mark as `_w` so selection boxes are tight.
return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
}
default: return { x: a.x, y: a.y, w: a.w, h: a.h };
}
}
/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */
export function translatePatch(a, dx, dy) {
if (RECT_LIST_KINDS.has(a.kind)) {
return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
}
if (SEGMENT_KINDS.has(a.kind)) {
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
}
if (a.kind === "pen") return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
return { x: a.x + dx, y: a.y + dy };
}
// ---- resizing ---------------------------------------------------------
//
// Handles are named by the corner or edge they sit on, in screen terms: "nw"
// is top-left as the reader sees it. User space has y running the other way,
// so "n" moves the box's y + h and "s" moves its y. Keeping the names in
// reader terms means the cursor CSS and the drag maths agree with what the
// user is doing with the mouse.
export const BOX_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
export const CORNER_HANDLES = ["nw", "ne", "se", "sw"];
/** Smallest box a resize may produce, in points. */
export const MIN_EXTENT = 4;
export const MIN_FONT = 4;
export const MAX_FONT = 288;
/** Which handles a mark offers. Segments get their two endpoints instead. */
export function handlesFor(a) {
if (SEGMENT_KINDS.has(a.kind)) return ["p1", "p2"];
if (a.kind === "text") return CORNER_HANDLES; // uniform scale only
return BOX_HANDLES;
}
/**
* The box a drag of `handle` produces, given the mark's current bounds and a
* user-space delta. Anchors the opposite corner or edge, and never inverts:
* dragging the top edge past the bottom stops at the minimum rather than
* flipping the mark over, which is disorienting mid-gesture.
*/
export function resizeBox(b, handle, dx, dy, { uniform = false } = {}) {
let { x, y, w, h } = b;
const east = handle.includes("e"), west = handle.includes("w");
const north = handle.includes("n"), south = handle.includes("s");
if (uniform && (east || west) && (north || south)) {
// Corner drag on a uniform mark: one scale factor, taken from whichever
// axis moved further, so the mark never distorts.
const sx = (w + (east ? dx : -dx)) / (w || 1);
const sy = (h + (north ? dy : -dy)) / (h || 1);
const k = Math.max(MIN_EXTENT / Math.max(w, h, 1), Math.abs(sx) > Math.abs(sy) ? sx : sy);
const nw = Math.max(MIN_EXTENT, w * k), nh = Math.max(MIN_EXTENT, h * k);
return {
x: west ? x + w - nw : x,
y: south ? y + h - nh : y,
w: nw, h: nh,
};
}
if (east) w = Math.max(MIN_EXTENT, w + dx);
if (west) { const nw2 = Math.max(MIN_EXTENT, w - dx); x = x + w - nw2; w = nw2; }
if (north) h = Math.max(MIN_EXTENT, h + dy);
if (south) { const nh2 = Math.max(MIN_EXTENT, h - dy); y = y + h - nh2; h = nh2; }
return { x, y, w, h };
}
/**
* Map a mark from one bounding box to another. One function for every kind,
* so a new mark type gets resizing for free as long as boundsOf knows it.
*/
export function scaleToBox(a, from, to) {
const sx = from.w > 0.001 ? to.w / from.w : 1;
const sy = from.h > 0.001 ? to.h / from.h : 1;
const fx = (x) => to.x + (x - from.x) * sx;
const fy = (y) => to.y + (y - from.y) * sy;
if (RECT_LIST_KINDS.has(a.kind)) {
return { rects: (a.rects || []).map((r) => ({ x: fx(r.x), y: fy(r.y), w: r.w * sx, h: r.h * sy })) };
}
if (SEGMENT_KINDS.has(a.kind)) {
return { x1: fx(a.x1), y1: fy(a.y1), x2: fx(a.x2), y2: fy(a.y2) };
}
if (a.kind === "pen") {
return { pts: (a.pts || []).map(([x, y]) => [fx(x), fy(y)]) };
}
if (a.kind === "text") {
// Text scales by font size, not by stretching glyphs. `y` is the top of
// the block, which is where the new box's top is.
const size = clampFont((a.size || 12) * (sy || 1));
return { size, x: to.x, y: to.y + to.h };
}
return { x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h) };
}
export function clampFont(n) {
return Math.min(MAX_FONT, Math.max(MIN_FONT, Math.round(n * 10) / 10));
}

View file

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