theseus/bundled-addons/pdf-editor/editor.html
Local Dev e1b5e60aaf feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser
A PDF that needs a signature, a highlight or a page removed currently sends
the user out to a desktop application or, worse, to a web service that wants
the document uploaded first. Both are poor answers for a browser whose point
is that nothing has to leave the machine. This is a full-tab editor that opens
a PDF, marks it up, fills its forms and saves a new copy, entirely locally.

Two engines, vendored rather than installed, because an add-on ships as a
self-contained folder over the signed update channel and nothing runs a
package manager on the way: pdf.js reads and renders, pdf-lib writes. They
share no state. Everything in between lives in PDF user space — points,
origin bottom-left — which is the one coordinate vocabulary both speak, so a
mark survives zooming, rotating and reordering with no conversion table and
save-time needs to know nothing about how a page happened to be displayed.

The page strip is built from pdf.js's PDFPageView components rather than its
PDFViewer, which renders pages in the file's own order and cannot hide,
reorder or individually rotate one — three of the features here. Text layers
are ours and stay attached for every page, drawn or not, because Theseus's
find bar is Chromium's findInPage over the live DOM and a torn-down text layer
is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100%
is 3.4 MB of bitmap.

Redaction is the part worth being careful about. A black box over text hides
nothing — the text stays in the content stream and comes straight out of a
copy-paste — so the editor says so in a modal before the tool can be used,
and on save rebuilds each redacted page as an image, which genuinely removes
it. Pages that were not redacted are untouched. Form widgets and links are
kept, since they were never the leak.

Saving never writes over the original: every save reloads the source bytes and
replays the session onto a fresh copy, so a botched save cannot poison the
next one.

Out of scope for this first version: editing the text that is already in the
document, and writing XFA forms back (pdf-lib cannot, so those are fill-and-
print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00

216 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>