diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca31e12c..d689b01a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The release run heads these entries with the version and opens a fresh - The rendered pdf view exposes `odr.annotation`: the five tools, live preview and undo, whose `getAnnotations()` produces exactly what `annotate` takes. + It marks on `mark()`, and `setOptions` holds the gesture policy: what marks, + which pointers draw, what a touch on an armed page does. - `PdfFile::is_annotatable` answers for the file what the `annotate` capability answers for the format, and narrows it. Encrypted and repaired pdfs say no. diff --git a/docs/design/README.md b/docs/design/README.md index 7e0aedf3f..09c5a1c9f 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -8,9 +8,6 @@ - [The v7 public API](api-v7.md) — what the next major removes from `src/odr/*.hpp`, why, and in which pull requests: one road per operation, and the removals this document's *Open tasks* deferred until a major. -- [PDF annotation design](pdf-annotation.md) — markup annotations (highlight, - freehand ink) written back as standard annotations via an incremental update: - why no PDF library, the JSON wire format, and the effort it costs. ## Diagrams diff --git a/docs/design/pdf-annotation.md b/docs/design/pdf-annotation.md deleted file mode 100644 index af845bae1..000000000 --- a/docs/design/pdf-annotation.md +++ /dev/null @@ -1,305 +0,0 @@ -# PDF annotation design - -Status: **landed** (#843–#850). This records why the markup annotation feature -is built the way it is — the decisions, and the alternatives they beat — for -whoever changes it next. It is a record, not a plan. - -Scope is **markup only**: draw on top of a page, highlight/underline/strike -text. Editing or removing the *existing* text of a PDF is explicitly out — that -is a different feature with a different cost. - -Related: [`editing.md`](editing.md) for ODF/OOXML content editing, which this is -independent of (decision 1), and -[`pdf/AGENTS.md`](../../src/odr/internal/pdf/AGENTS.md) for the read side this -builds on. - -## Problem - -`pdf/` is read-only: it parses and renders to HTML, and has no writer. We want -the user to highlight text and draw on a page in the browser, and to persist -that into the PDF as **standard annotations**, so every other viewer sees them — -not as a rasterized overlay and not as a sidecar file. - -## Why this is cheap here - -Annotations are **additive**. Nothing in the page content stream changes, no -object is renumbered, no page-tree meaning is touched. A highlight is one new -annotation object, one new appearance-stream object, and a rewritten page -dictionary — appended to the file as an incremental update (7.5.6). - -Most of the machinery is already in the tree: - -| Piece | Where | State | -|---|---|---| -| Object serialization | `pdf_object.cpp` `to_stream`/`operator<<` | Emits real PDF syntax, not a debug dump. Gaps: `StandardString` has a `// TODO escape`, `Name` does not `#`-escape, reals must not reach exponent form | -| Whole-file assembly with xref + `startxref` | `test/.../pdf_test_file_builder.cpp` | Works, but is marked *"must never grow into a writer API"* — `src/` gets its own | -| Original bytes | `PdfFile::m_file` | An incremental update is copy-then-append | -| Page geometry | `begin_page()` → `to_box` (`html/pdf_file.cpp`) | User space → page box in points, `/CropBox` origin and `/Rotate` folded in; needs `Transform2D::inverse` | -| **Appearance-stream rendering** | `Annotation::appearance`, `extract_annotation` | `/AP /N` already resolves (through `/AS`), fits `/Matrix`-transformed `/BBox` onto `/Rect`, and runs like a `Do` | -| **Blend modes** | `blend_mode_to_css` | `/BM /Multiply` — what a highlight needs — already maps to `mix-blend-mode` | -| Selectable text with geometry | the `.sel` dual layer | `Range.getClientRects()` yields highlight quads directly | -| Embedded script/style assets | `frontend.cpp` (`viewport_js`, `search_js`) | A new `pdf_annotation_js` slots in unchanged | - -The appearance rendering is the load-bearing one: **what we write, we already -render**, so the round-trip is self-verifying and the feature needs no new -rendering code. - -## Decisions - -### 1. File-level API on `PdfFile`, addressed by page + geometry - -PDF has no `abstract::Document` (`is_decodable()` is `false`; there is only an -`HtmlService`), so `Document::edit`/`save` do not apply. The entry point hangs -off `PdfFile` and addresses annotations by **page index and user-space -geometry**, never by `ElementIdentifier`. - -**Why it matters:** this sidesteps the id-stability linchpin that -[`editing.md`](editing.md) Phase 0 is blocked on. No append-only/tombstone -discipline, no session-stable ids, no JS/C++ op-semantics drift, so no -conformance corpus. **This feature does not depend on the deferred editing work -and must not be sequenced behind it.** - -### 2. Incremental update, never a rewrite - -Append a new section to the original bytes; never re-serialize the document. - -**Why:** a full rewrite means re-emitting every object we parsed, which turns -every read-side gap (an unmodelled key, a filter we pass through, an object -stream we did not recompress) into data loss. An incremental update copies the -original byte-for-byte and is the mechanism the format provides for exactly -this. It also keeps an existing signature valid over its own byte range — the -file is flagged *modified after signing*, not broken, which is what Acrobat does -too. - -**Consequence:** files we could only open by the forward-scan xref rebuild must -be **refused**, not annotated — appending onto a structure whose own xref is -broken produces a file that only we can read. - -### 3. Always write an appearance stream - -Every annotation carries `/AP /N` — a form XObject we generate — even where a -viewer could synthesize one from `/QuadPoints` or `/InkList`. - -**Why:** our own renderer paints annotations *only* through `/AP /N` -(`pdf/AGENTS.md`), so without one the annotation is invisible in odr. Writing it -is also the interop-safe choice: viewers disagree on synthesized appearances, -and none disagrees about a form XObject. The constraint and the correct answer -coincide. - -### 4. Hand-roll the writer; take no PDF library - -| Option | License | Verdict | -|---|---|---| -| MuPDF (`pdf_annot`, exactly this feature set) | AGPL / commercial | Incompatible with MPL-2.0 | -| PDFium (`FPDFAnnot_*`, `FPDF_INCREMENTAL`) | BSD-3 | License fine; it is a full renderer + parser, and pulling that in to append four dictionaries contradicts the module's premise | -| PoDoFo | LGPL | Static linking on iOS is a licensing problem; second object model beside ours | -| QPDF | Apache-2.0 | Clean, good object model — but a second PDF parser next to the one we wrote. Worth revisiting only for a *general* writer | -| pdf-lib / pdfAnnotate (JS) | MIT | No C++ work, but duplicates file writing in JS, does not serve the droid/ios native path, and adds a JS dependency the project avoids | - -The part that normally makes PDF writing expensive — building the object graph, -embedding fonts, emitting content streams — we either already have or do not -need. The genuinely new logic is a few hundred lines. - -### 5. Fat browser, same as `editing.md` - -The browser owns the pending annotations for the session and renders them live; -on save it hands C++ a JSON list which is applied in one shot. C++ re-validates -and fails fast. - -**Why:** identical reasoning to [`editing.md`](editing.md) decision 1, and here -the drift risk that decision accepted does not exist — the payload is -declarative geometry, not an operation log with semantics to reimplement on both -sides. - -### 6. Refuse encrypted files in v1 - -New strings and streams must be encrypted with the file key, and the module -deliberately never retains the derived key (it lives inside `Decryptor`, with no -accessor). `crypto::util` has `encrypt_aes_cbc` and RC4 is symmetric, so this is -reachable later — PKCS#5 padding, a random IV, and a key accessor — but v1 -throws rather than writing a file whose new objects are in the clear and -therefore unreadable. - -## Wire format - -One JSON document, produced by the browser, consumed by `PdfFile::annotate`. -Coordinates are **PDF user space** (points, y-up, the page's own space) — the -browser has already applied `to_box⁻¹`, so C++ does no geometry beyond building -the appearance. - -```jsonc -{ - "version": 1, - "annotations": [ - { - "page": 0, // 0-based index into the page tree - "type": "highlight", // highlight | underline | strikeOut | squiggly - "quads": [ // one per line covered; user space - [72.0, 700.0, 300.0, 700.0, // x1 y1 x2 y2 (upper-left, upper-right) - 72.0, 688.0, 300.0, 688.0], // x3 y3 x4 y4 (lower-left, lower-right) - [72.0, 686.0, 180.0, 686.0, 72.0, 674.0, 180.0, 674.0] - ], - "color": [1.0, 0.9, 0.2], // DeviceRGB, 0..1 - "opacity": 1.0, // /CA - "author": "…", // /T, optional - "contents": "…" // /Contents, optional - }, - { - "page": 0, - "type": "ink", - "strokes": [ // one entry per pen-down..pen-up - [100.0, 500.0, 104.5, 502.0, 110.0, 507.5] // flat x y pairs - ], - "color": [0.9, 0.1, 0.1], - "width": 2.0, // /BS /W, points - "opacity": 1.0 - }, - { "page": 1, "type": "delete", "name": "odr-3f2a91c4" } // /NM of one we wrote - ] -} -``` - -Notes on the shape: - -- **`quads` order is upper-left, upper-right, lower-left, lower-right.** The - spec's stated order (12.5.6.10) is counterclockwise; every implementation - writes the Z-order above, and `pdfAnnotate`'s documentation says as much - outright, as does Phase 2's appearance-less experiment. Follow the - implementations, and say so in a comment at the one place that emits it. -- **`delete` only names an annotation we wrote**, identified by the `/NM` we - minted. Deleting a foreign annotation is out of scope: we would have to prove - nothing else references it. -- The payload is one-way and non-invertible; undo lives in the browser, exactly - as [`editing.md`](editing.md) decision 6 argues. -- `version` is the drift guard — a payload from a newer frontend is rejected, - not partially understood. - -## What gets written - -For a highlight, three objects and one rewrite: - -``` - -12 0 obj << /Type /Annot /Subtype /Highlight /Rect [72 674 300 700] - /QuadPoints [72 700 300 700 72 688 300 688 …] - /C [1 0.9 0.2] /CA 1 /F 4 /NM (odr-3f2a91c4) /M (D:20260906120000Z) - /AP << /N 13 0 R >> >> endobj -13 0 obj << /Type /XObject /Subtype /Form /BBox [72 674 300 700] - /Group << /Type /Group /S /Transparency /CS /DeviceRGB >> - /Resources << /ExtGState << /G0 << /BM /Multiply /ca 1 >> >> >> - /Length n >> stream - /G0 gs 1 0.9 0.2 rg 72 688 228 12 re f … - endstream endobj -5 0 obj << … original page dictionary …, /Annots [9 0 R 12 0 R] >> endobj -xref (only the changed ids) -trailer << /Size … /Root … /Prev /ID [ ] >> -startxref … -``` - -Ink is the same shape: `/Subtype /Ink`, `/InkList [[x y …]]`, `/BS << /W w >>`, -and an appearance of `m`/`c`/`S` with round caps and joins. - -The transparency group on the form is what makes `/BM /Multiply` composite -against the page rather than against the form's own backdrop. - -### Validated against real viewers - -A throwaway script wrote exactly the above — a highlight and an ink stroke, as -one incremental update — onto `odr-public/pdf/style-various-1.pdf`, before any -of it was committed to C++. `qpdf --check` passes and four independent engines -paint both annotations with the page text showing through the highlight: -ghostscript, PDFium (Chrome), CoreGraphics (Preview), and **our own renderer**, -which emits the highlight as `` and the ink as a round-capped stroke. - -So the following are facts, not assumptions: the transparency group composites -against the page rather than a black backdrop; appending to a page's *existing* -`/Annots` array works and the newer page object wins; a classic section listing -only the changed ids is accepted everywhere; and `to_box` places the result -correctly (user-space y 700/688 arrived at page-box y 92/104). - -Three things the spike did **not** settle, and Phase 1 and 2 owe tests for each: - -- **QuadPoints ordering.** With an `/AP` present, the appearance is what every - one of those engines painted — the `/QuadPoints` were never consulted. - Settled in Phase 2 with an appearance-less annotation instead. -- **A page dictionary inside an object stream**, and **appending to a file - whose newest section is an xref stream.** The spike's fixture had neither; - Phase 1's tests cover both. - -## How it landed - -| | | -|---|---| -| #843 | Object serialization made writable — escaping, and reals through `to_string_significant` rather than `{:.4g}`, which both rounded to four significant digits and reached for an exponent form 7.3.3 has no syntax for. `Transform2D::inverse`. | -| #844 | The parse facts a writer needs: `start_xref_position()`, `xref_kind()`, `is_recovered()`, `highest_object_id()`. The first two are optional and recovery clears them, so the missing value and decision 2's refusal gate are the same fact. | -| #845 | `IncrementalWriter`. Verified plumbing-first: a no-op update that re-parses identically, then a `/Rotate` rewrite, before any annotation semantics existed to blame. | -| #846 | The object-stream page rewrite, which modern producers make the common case. It already worked. | -| #847 | `write_text_markup`, `write_ink`, `append_page_annotations`. | -| #848 | `PdfFile::annotate` and the wire format above; the `annotate` capability. | -| #849 | `odr.annotation` and the page attributes it reads. | -| #850 | python, java, swift and wasm. | - -Two things cost more than the estimate said, and both were found by looking -rather than by testing: - -- **`mix-blend-mode` on a shape inside an svg composites against that svg's own - canvas**, not against the page, so the first highlight overlay painted over - the glyphs — the exact failure `/BM /Multiply` exists to prevent. The blend - belongs on the overlay element, which forces a separate overlay for the - washes. -- **`selectionchange` fires on every character a drag covers.** Marking on the - first one and clearing the selection mid-gesture turned one intended - highlight into six fragments. The mark now waits for the pointer to come up. - -## Verified against - -Six engines read what we write: ghostscript, PDFium (Chrome), CoreGraphics -(Preview), pdf.js, qpdf's structural check, and our own renderer — which is the -self-verifying one, since it paints only from `/AP /N`. Acrobat itself has not -been tried; nothing in the file is Acrobat-specific, but that is an assumption -rather than a result. - -The reference-output snapshot covers every rendered pdf page. - -## What the writer unlocks next - -Nearly free now, all reusing the same appearance machinery: - -- **Underline / StrikeOut / Squiggly** — the highlight path with a different - appearance and subtype. -- **Square / Circle / Line** — the ink path. -- **Sticky note** (`/Text` + `/Contents`) — trivial in the file; the cost is the - popup UI. -- **Page rotate** — rewrite `/Rotate` on the page dictionary. -- **Page delete / reorder** — rewrite `/Kids` and `/Count`. -- **`/Info` metadata edit** — one new dictionary. - -Medium: - -- **FreeText** — needs `/DA` and a font resource; generate the appearance with a - standard-14 Helvetica, which the substitution path already handles. -- **Stamp / signature image** — an image XObject in the appearance; `png/` - already encodes. -- **Flatten annotations** — append to the content stream. -- **AcroForm field fill** — the writer makes it possible, but regenerating - appearances from `/V` and `/DA` is the real work, and `pdf/AGENTS.md` scopes - form interactivity out today. -- **Deleting a foreign annotation** — we remove only what we wrote, identified - by its `/NM`; removing someone else's means proving nothing references it. - -## Open questions - -- **Link overlays vs. the highlight tool.** `` overlays sit above the `.sel` - layer and block selection (`pdf/AGENTS.md` roadmap), so text under a link - cannot be highlighted by selecting it. The markup tools capture no pointer - events, so this is the link overlay's problem rather than the annotator's — - but it is user-visible now rather than theoretical. Does it force the reverted - `elementFromPoint` workaround (commit `5cfa8a09`) back onto the table? -- **Annotating a linearized file** breaks its linearization: the `/Linearized` - dictionary then describes a prefix that is no longer the whole file. Viewers - cope and Acrobat does the same — do we say so and move on, or de-linearize? -- **Encrypted files** are refused (decision 6). Is that acceptable for the - app's real corpus, or does the `Decryptor` key accessor need to land? -- **Where does the pending-annotation state live across a reload** in the mobile - WebView — the browser only, or does the host persist the payload? diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index a12b4728b..0b1d79852 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -709,14 +710,18 @@ constexpr std::string_view viewport_js = R"js( })(); )js"; -/// Text search over the rendered page, format-agnostic: it walks text nodes. +/// The annotation overlays, and what `setOptions` writes into css. constexpr std::string_view pdf_annotation_css = R"css( .an{position:absolute;inset:0;overflow:visible;pointer-events:none;z-index:3} /* on the overlay, not on the shape: a blend inside an svg composites against the svg's own canvas, so the highlight would paint over the glyphs */ .an-m{mix-blend-mode:multiply} -.p.an-draw .an{pointer-events:auto;cursor:crosshair;touch-action:none} +/* the two properties `setOptions` writes */ +.p.an-draw .an{pointer-events:auto;cursor:crosshair;touch-action:var(--odr-an-touch,none)} .p.an-draw .t,.p.an-draw .sel{pointer-events:none} +/* neither may interrupt a stroke */ +.p.an-draw{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none} +html.an-drawing{overscroll-behavior:var(--odr-an-overscroll,contain)} )css"; /// `odr.annotation`: the pending markup a viewer draws, and the payload /// `PdfFile::annotate` takes. Geometry is kept in page-box points and mapped @@ -735,6 +740,14 @@ constexpr std::string_view pdf_annotation_js = R"js( var pending = []; var nextId = 1; + /// Gesture policy, the viewer's to set. `inkPointerTypes` null takes any. + var options = { + markOnSelection: false, + inkPointerTypes: null, + touchAction: "none", + overscrollBehavior: "contain", + }; + function pages() { return Array.prototype.slice.call( document.querySelectorAll("[data-odr-space]") @@ -804,34 +817,33 @@ constexpr std::string_view pdf_annotation_js = R"js( ); } + function inkPath(strokes) { + return strokes + .map(function (s) { + var d = "M " + s[0] + " " + s[1]; + for (var i = 2; i < s.length; i += 2) { + d += " L " + s[i] + " " + s[i + 1]; + } + return d; + }) + .join(" "); + } + function draw(annotation) { var page = pageOf(annotation.page); if (!page) { - return; + return null; } var svg = overlay(page, annotation.type === "highlight"); - var node; + var node = document.createElementNS(SVG, "path"); if (annotation.type === "ink") { - node = document.createElementNS(SVG, "path"); - node.setAttribute( - "d", - annotation.strokes - .map(function (s) { - var d = "M " + s[0] + " " + s[1]; - for (var i = 2; i < s.length; i += 2) { - d += " L " + s[i] + " " + s[i + 1]; - } - return d; - }) - .join(" ") - ); + node.setAttribute("d", inkPath(annotation.strokes)); node.setAttribute("fill", "none"); node.setAttribute("stroke", css(annotation.color)); node.setAttribute("stroke-width", annotation.width); node.setAttribute("stroke-linecap", "round"); node.setAttribute("stroke-linejoin", "round"); } else { - node = document.createElementNS(SVG, "path"); node.setAttribute("d", annotation.boxes.map(barPath(annotation.type)).join(" ")); if (annotation.type === "squiggly") { node.setAttribute("fill", "none"); @@ -843,6 +855,7 @@ constexpr std::string_view pdf_annotation_js = R"js( } node.setAttribute("data-odr-annotation", annotation.id); svg.appendChild(node); + return node; } /// The shape one covered box gets, in page-box points. @@ -879,10 +892,77 @@ constexpr std::string_view pdf_annotation_js = R"js( }); }); pending.forEach(draw); + // a rebuild throws away the node a live stroke draws into + strokeNode = stroke + ? document.querySelector('[data-odr-annotation="' + stroke.id + '"]') + : null; + } + + function pushBox(byPage, left, top, right, bottom) { + if (right - left < 0.5 || bottom - top < 0.5) { + return; + } + var page = pageAt((left + right) / 2, (top + bottom) / 2); + if (!page) { + return; + } + var index = +page.getAttribute("data-odr-page"); + var a = toBox(page, left, top); + var b = toBox(page, right, bottom); + (byPage[index] = byPage[index] || []).push([a[0], a[1], b[0], b[1]]); + } + + /// The selection-layer runs a range touches; a spacer carries no text. + function selectedRuns(selection, range) { + var scope = range.commonAncestorContainer; + if (!scope.querySelectorAll) { + scope = scope.parentElement; + } + if (!scope) { + return []; + } + var self = scope.closest ? scope.closest(".sr") : null; + if (self) { + return self.textContent.length > 0 ? [self] : []; + } + return Array.prototype.filter.call( + scope.querySelectorAll(".sr"), + function (run) { + return run.textContent.length > 0 && selection.containsNode(run, true); + } + ); + } + + /// One run's covered box; a partly selected run takes its horizontal edges + /// from the rects, clamped to the run. + function runBox(byPage, run, rects, selection) { + var box = run.getBoundingClientRect(); + var left = box.left; + var right = box.right; + if (!selection.containsNode(run, false)) { + left = Infinity; + right = -Infinity; + for (var i = 0; i < rects.length; ++i) { + var rect = rects[i]; + if ( + rect.width < 0.5 || + rect.bottom <= box.top || + rect.top >= box.bottom || + rect.right <= box.left || + rect.left >= box.right + ) { + continue; + } + left = Math.min(left, Math.max(rect.left, box.left)); + right = Math.max(right, Math.min(rect.right, box.right)); + } + } + pushBox(byPage, left, box.top, right, box.bottom); } - /// The boxes a selection covers, per page, in page-box points. Zero-width - /// rects are the selection layer's spacer spans and carry no text. + /// The boxes a selection covers, per page, in page-box points. Vertically + /// the run's box, not the range's rect: that rect follows whatever font the + /// browser substituted for the layer. function selectionBoxes() { var selection = window.getSelection(); var byPage = {}; @@ -890,20 +970,17 @@ constexpr std::string_view pdf_annotation_js = R"js( return byPage; } for (var r = 0; r < selection.rangeCount; ++r) { - var rects = selection.getRangeAt(r).getClientRects(); - for (var i = 0; i < rects.length; ++i) { - var rect = rects[i]; - if (rect.width < 0.5 || rect.height < 0.5) { - continue; - } - var page = pageAt(rect.left + rect.width / 2, rect.top + rect.height / 2); - if (!page) { - continue; + var range = selection.getRangeAt(r); + var rects = range.getClientRects(); + var runs = selectedRuns(selection, range); + for (var i = 0; i < runs.length; ++i) { + runBox(byPage, runs[i], rects, selection); + } + if (runs.length === 0) { + // no selection layer under it + for (var k = 0; k < rects.length; ++k) { + pushBox(byPage, rects[k].left, rects[k].top, rects[k].right, rects[k].bottom); } - var index = +page.getAttribute("data-odr-page"); - var a = toBox(page, rect.left, rect.top); - var b = toBox(page, rect.right, rect.bottom); - (byPage[index] = byPage[index] || []).push([a[0], a[1], b[0], b[1]]); } } return byPage; @@ -920,7 +997,12 @@ constexpr std::string_view pdf_annotation_js = R"js( return null; } - function markSelection() { + /// One annotation per page the selection covers. `keep` holds the selection, + /// which the automatic path cannot: the next `selectionchange` re-marks it. + function markSelection(keep) { + if (!tool || tool === "ink") { + return false; + } var byPage = selectionBoxes(); var added = false; Object.keys(byPage).forEach(function (index) { @@ -934,32 +1016,64 @@ constexpr std::string_view pdf_annotation_js = R"js( added = true; }); if (added) { - window.getSelection().removeAllRanges(); + if (!keep) { + window.getSelection().removeAllRanges(); + } redraw(); } return added; } +)js"; +/// The rest of it; see `Asset::content_tail`. +constexpr std::string_view pdf_annotation_js_tail = R"js( var stroke = null; + var strokeNode = null; + var strokePointer = null; + var strokeData = ""; + var strokeFrame = 0; var pointerDown = false; var settle = null; + /// One dom write per frame; a pen reports faster than the page paints. + function flushStroke() { + strokeFrame = 0; + if (strokeNode) { + strokeNode.setAttribute("d", strokeData); + } + } + + function scheduleFlush() { + if (!strokeFrame) { + strokeFrame = window.requestAnimationFrame(flushStroke); + } + } + /// A drag fires `selectionchange` on every character it covers, so the mark /// waits for the gesture that makes it to end rather than taking the first /// character and tearing the selection out from under the pointer. function scheduleMark() { - if (!tool || tool === "ink" || pointerDown) { + if (!options.markOnSelection || !tool || tool === "ink" || pointerDown) { return; } window.clearTimeout(settle); - settle = window.setTimeout(markSelection, 50); + settle = window.setTimeout(function () { + markSelection(false); + }, 50); + } + + function inkTakes(event) { + return ( + options.inkPointerTypes === null || + options.inkPointerTypes.indexOf(event.pointerType) !== -1 + ); } function onPointerDown(event) { pointerDown = true; // a new gesture supersedes a mark the previous one had queued window.clearTimeout(settle); - if (tool !== "ink" || event.button !== 0) { + if (tool !== "ink" || event.button !== 0 || !inkTakes(event)) { return; } var page = pageAt(event.clientX, event.clientY); @@ -977,40 +1091,68 @@ constexpr std::string_view pdf_annotation_js = R"js( width: width, }; pending.push(stroke); + strokePointer = event.pointerId; + strokeData = "M " + p[0] + " " + p[1]; + strokeNode = draw(stroke); page.setPointerCapture(event.pointerId); } function onPointerMove(event) { - if (!stroke) { + if (!stroke || event.pointerId !== strokePointer) { return; } var page = pageOf(stroke.page); - var p = toBox(page, event.clientX, event.clientY); var points = stroke.strokes[0]; - // drop the sub-point jitter a pointer emits while nearly still - if ( - Math.abs(p[0] - points[points.length - 2]) + - Math.abs(p[1] - points[points.length - 1]) < - 0.5 - ) { - return; + // a synthetic event coalesces none, and is its own sample + var samples = + typeof event.getCoalescedEvents === "function" + ? event.getCoalescedEvents() + : []; + if (samples.length === 0) { + samples = [event]; + } + var appended = false; + for (var i = 0; i < samples.length; ++i) { + var p = toBox(page, samples[i].clientX, samples[i].clientY); + // drop the sub-point jitter a pointer emits while nearly still + if ( + Math.abs(p[0] - points[points.length - 2]) + + Math.abs(p[1] - points[points.length - 1]) < + 0.5 + ) { + continue; + } + points.push(p[0], p[1]); + strokeData += " L " + p[0] + " " + p[1]; + appended = true; + } + if (appended) { + scheduleFlush(); } - points.push(p[0], p[1]); - redraw(); } - function onPointerUp() { + function onPointerUp(event) { pointerDown = false; scheduleMark(); - if (!stroke) { + if (!stroke || (event && event.pointerId !== strokePointer)) { return; } - if (stroke.strokes[0].length < 4) { + var points = stroke.strokes[0]; + if (points.length < 4) { // a tap with no drag leaves a dot, which is a legitimate mark - stroke.strokes[0].push(stroke.strokes[0][0], stroke.strokes[0][1]); + points.push(points[0], points[1]); + strokeData += " L " + points[0] + " " + points[1]; } + flushStroke(); stroke = null; - redraw(); + strokeNode = null; + strokePointer = null; + } + + function applyOptions() { + var style = document.documentElement.style; + style.setProperty("--odr-an-touch", options.touchAction); + style.setProperty("--odr-an-overscroll", options.overscrollBehavior); } document.addEventListener("pointerdown", onPointerDown); @@ -1019,6 +1161,7 @@ constexpr std::string_view pdf_annotation_js = R"js( document.addEventListener("pointercancel", onPointerUp); document.addEventListener("selectionchange", scheduleMark); window.addEventListener("resize", redraw); + applyOptions(); odr.annotation = { /// null, "highlight", "underline", "strikeOut", "squiggly" or "ink". @@ -1027,6 +1170,7 @@ constexpr std::string_view pdf_annotation_js = R"js( pages().forEach(function (page) { page.classList.toggle("an-draw", tool === "ink"); }); + document.documentElement.classList.toggle("an-drawing", tool === "ink"); }, getTool: function () { return tool; @@ -1038,6 +1182,28 @@ constexpr std::string_view pdf_annotation_js = R"js( setWidth: function (value) { width = Number(value); }, + /// Merged into what is set; an unknown key throws. + setOptions: function (value) { + Object.keys(value || {}).forEach(function (key) { + if (!Object.prototype.hasOwnProperty.call(options, key)) { + throw new Error("odr.annotation: unknown option " + key); + } + options[key] = value[key]; + }); + applyOptions(); + }, + getOptions: function () { + var copy = {}; + Object.keys(options).forEach(function (key) { + copy[key] = options[key]; + }); + return copy; + }, + /// Marks the selection with the armed tool, and answers whether anything + /// was added. The selection is left standing. + mark: function () { + return markSelection(true); + }, /// What is pending, newest last. Geometry is in page-box points. list: function () { return pending.slice(); @@ -1098,6 +1264,7 @@ constexpr std::string_view pdf_annotation_js = R"js( })(); )js"; +/// Text search over the rendered page, format-agnostic: it walks text nodes. constexpr std::string_view search_js = R"js( (function () { "use strict"; @@ -2049,8 +2216,22 @@ struct Asset { std::string_view mime_type; std::string_view name; std::string_view content; + std::string_view content_tail{}; ///< written straight after @ref content }; +/// msvc caps a string literal at 16380 bytes, and a windows checkout spends one +/// more per line, so a script outgrowing that is split over two. +consteval bool fits_a_literal(const std::string_view content) { + return content.size() + std::ranges::count(content, '\n') <= 16380; +} + +static_assert(fits_a_literal(viewport_js)); +static_assert(fits_a_literal(search_js)); +static_assert(fits_a_literal(spreadsheet_js)); +static_assert(fits_a_literal(text_js)); +static_assert(fits_a_literal(pdf_annotation_js)); +static_assert(fits_a_literal(pdf_annotation_js_tail)); + constexpr Asset document_css_asset{HtmlResourceType::css, "text/css", "document.css", document_css}; constexpr Asset document_dark_css_asset{HtmlResourceType::css, "text/css", @@ -2093,7 +2274,8 @@ constexpr Asset pdf_annotation_css_asset{HtmlResourceType::css, "text/css", "pdf-annotation.css", pdf_annotation_css}; constexpr Asset pdf_annotation_js_asset{HtmlResourceType::js, "text/javascript", - "pdf-annotation.js", pdf_annotation_js}; + "pdf-annotation.js", pdf_annotation_js, + pdf_annotation_js_tail}; /// Appends @p asset to @p resources; `nullopt` to embed it. HtmlResourceLocation locate(const Asset &asset, const HtmlConfig &config, @@ -2101,7 +2283,9 @@ HtmlResourceLocation locate(const Asset &asset, const HtmlConfig &config, const odr::HtmlResource resource = HtmlResource::create( asset.type, std::string(asset.mime_type), std::string(asset.name), std::string(asset.name), - odr::File::from_memory(std::string(asset.content)), true, false, true); + odr::File::from_memory(std::string(asset.content) + + std::string(asset.content_tail)), + true, false, true); HtmlResourceLocation location = config.resource_locator(resource, config); resources.emplace_back(resource, location); return location; @@ -2140,7 +2324,7 @@ void write_style(const Asset &asset, const WritingState &state, } state.out().write_header_style_begin(media); - state.out().out() << asset.content; + state.out().out() << asset.content << asset.content_tail; state.out().write_header_style_end(); } @@ -2159,7 +2343,7 @@ void write_script(const Asset &asset, const WritingState &state) { } state.out().write_script_begin(); - state.out().out() << asset.content; + state.out().out() << asset.content << asset.content_tail; state.out().write_script_end(); } diff --git a/src/odr/internal/pdf/AGENTS.md b/src/odr/internal/pdf/AGENTS.md index dc10ee53a..3a279f29d 100644 --- a/src/odr/internal/pdf/AGENTS.md +++ b/src/odr/internal/pdf/AGENTS.md @@ -7,7 +7,7 @@ invariants, and where things live. Reference links live in [`README.md`](README. **Goal.** Faithful HTML for common real-world PDFs through a pure-serialization pipeline (no native renderer). Reading is what the module mostly is; the one thing it writes is **markup annotations**, appended without disturbing anything -already in the file (see [`docs/design/pdf-annotation.md`](../../../../docs/design/pdf-annotation.md)). +already in the file. The file-format, text-extraction, font and graphics foundations are in place; what remains is interaction & navigation plus a tail of known gaps (see *Roadmap*). @@ -278,9 +278,7 @@ fixtures are verified manually but not pinned. # Roadmap -Markup annotations have landed — see -[`docs/design/pdf-annotation.md`](../../../../docs/design/pdf-annotation.md) for -the decisions and what the writer unlocks next. The next feature cluster is +Markup annotations have landed. The next feature cluster is **interaction & navigation**; the rest is a tail of known gaps. Each remaining item gets its own detailed design before implementation. Grow the corpus alongside (odr-public fixtures + the PDF101 "nasty files" collection linked in `README.md`; assertion tests per feature). diff --git a/test/browser/annotation/README.md b/test/browser/annotation/README.md index e3a5824ca..7f99b4357 100644 --- a/test/browser/annotation/README.md +++ b/test/browser/annotation/README.md @@ -34,4 +34,8 @@ Why the harness is shaped this way: - **A drag is stepped through by hand** — pointer down, the selection extended a character at a time, pointer up. Chromium will not select text from a synthetic mouse event, and one `addRange` fires a single `selectionchange`, - so neither reaches the case a drag creates. + so neither reaches the case a drag creates. It is the one place that opts into + `markOnSelection`; elsewhere the checks call `mark()`. +- **Nothing waits on a frame** — a background window throttles + `requestAnimationFrame`, so the live stroke is asserted after `pointerup`, + which flushes. diff --git a/test/browser/annotation/serve b/test/browser/annotation/serve index 40e0781cc..bf551b43d 100755 --- a/test/browser/annotation/serve +++ b/test/browser/annotation/serve @@ -21,6 +21,7 @@ def extract(begin: str, end: str) -> str: def main() -> None: (HERE / "pdf-annotation.js").write_text( extract('constexpr std::string_view pdf_annotation_js = R"js(', ')js";') + + extract('constexpr std::string_view pdf_annotation_js_tail = R"js(', ')js";') ) (HERE / "pdf-annotation.css").write_text( extract('constexpr std::string_view pdf_annotation_css = R"css(', ')css";') diff --git a/test/browser/annotation/tests.html b/test/browser/annotation/tests.html index ba264d7de..6d24c1dca 100644 --- a/test/browser/annotation/tests.html +++ b/test/browser/annotation/tests.html @@ -44,6 +44,12 @@ white-space: pre; line-height: 1; } + /* as the real selection layer: an inline-block whose box is the laid-out + line, taller than the rect a range gets from the substituted font */ + .sr { + display: inline-block; + line-height: 1.6; + }
@@ -104,14 +110,25 @@ async function run() { check("the api is exposed", typeof api.getAnnotations === "function"); - // --- a selection becomes one quad in user space ----------------------- + // --- nothing is marked until the host asks ---------------------------- api.clear(); api.setTool("highlight"); api.setColor([1, 0.9, 0.2]); - const run1 = selectRun(0); + selectRun(0); await new Promise((r) => setTimeout(r, 100)); + check("a selection alone marks nothing", api.list().length === 0, api.list()); + + check("mark() takes the selection", api.mark() === true); + check("and it is one annotation", api.list().length === 1, api.list()); + check( + "the selection is left standing", + !window.getSelection().isCollapsed + ); + check("mark() on the same selection adds another", api.mark() === true); + api.undo(); - check("a selection makes one annotation", api.list().length === 1, api.list()); + // --- a selection becomes one quad in user space ----------------------- + const run1 = document.querySelector('[data-odr-page="0"] .sr'); const first = payload().annotations[0]; check("it names its page", first.page === 0, first.page); @@ -141,9 +158,24 @@ q ); - // --- a drag marks once, not once per character it covers ------------- + // the quad follows the run's box, not the substituted font's rect + const zoom0 = pageRect.width / page.offsetWidth; + const rangeRect = window.getSelection().getRangeAt(0).getClientRects()[0]; + check( + "the run's box is taller than the range's rect", + runRect.height > rangeRect.height + 1, + [runRect.height, rangeRect.height] + ); + check( + "the quad covers the run's box, not that rect", + near(q[1] - q[5], (runRect.height / zoom0) * 0.75), + [q[1] - q[5], (runRect.height / zoom0) * 0.75, (rangeRect.height / zoom0) * 0.75] + ); + + // --- opted in, a drag marks once, not once per character ------------ api.clear(); api.setTool("highlight"); + api.setOptions({ markOnSelection: true }); const text = document.querySelector('[data-odr-page="0"] .sr').firstChild; const pointer = (type) => document.dispatchEvent( @@ -162,10 +194,15 @@ api.list().length === 1, api.list().length ); + check( + "the automatic path takes the selection with it", + window.getSelection().isCollapsed + ); + api.setOptions({ markOnSelection: false }); api.clear(); api.setTool("highlight"); selectRun(0); - await new Promise((r) => setTimeout(r, 100)); + api.mark(); // --- the overlay is drawn, and the wash is the one that multiplies ---- const page1 = document.querySelector('[data-odr-page="0"]'); @@ -178,7 +215,7 @@ api.clear(); api.setTool("underline"); selectRun(0); - await new Promise((r) => setTimeout(r, 100)); + api.mark(); check( "an underline does not multiply", page1.querySelector("svg.an-m path") === null && @@ -189,7 +226,7 @@ api.clear(); api.setTool("highlight"); selectRun(1); - await new Promise((r) => setTimeout(r, 100)); + api.mark(); check("page two annotates as page 1", payload().annotations[0].page === 1); // --- ink through real pointer events --------------------------------- @@ -228,7 +265,7 @@ api.setTool("highlight"); selectRun(0); - await new Promise((r) => setTimeout(r, 100)); + api.mark(); const id = api.list()[api.list().length - 1].id; api.remove(id); check("remove drops it by id", !api.list().some((a) => a.id === id)); @@ -243,11 +280,94 @@ // --- the tool gates pointer capture ----------------------------------- api.setTool("ink"); check("ink arms the page for drawing", page1.classList.contains("an-draw")); + check( + "and the document, for the scroller it sits in", + document.documentElement.classList.contains("an-drawing") + ); api.setTool("highlight"); check( "a text tool leaves selection alone", - !page1.classList.contains("an-draw") + !page1.classList.contains("an-draw") && + !document.documentElement.classList.contains("an-drawing") + ); + api.setTool(null); + + // --- the gesture policy is the host's, and reaches the css ------------ + check( + "the defaults draw nothing on their own", + api.getOptions().markOnSelection === false && + api.getOptions().inkPointerTypes === null + ); + api.setTool("ink"); + check( + "a touch on the overlay does not pan by default", + getComputedStyle(page1.querySelector("svg.an")).touchAction === "none" + ); + api.setOptions({ touchAction: "pinch-zoom" }); + check( + "setOptions reaches the overlay", + getComputedStyle(page1.querySelector("svg.an")).touchAction === + "pinch-zoom", + getComputedStyle(page1.querySelector("svg.an")).touchAction ); + api.setOptions({ touchAction: "none" }); + check("getOptions reports what was set", api.getOptions().touchAction === "none"); + + let threw = false; + try { + api.setOptions({ nonesuch: 1 }); + } catch (e) { + threw = true; + } + check("an unknown option throws rather than being ignored", threw); + + // --- pointer types the host does not want are not drawn with ---------- + api.clear(); + api.setOptions({ inkPointerTypes: ["pen"] }); + const withType = (type, x, y) => + Object.assign(at(x, y), { pointerType: type, pointerId: 7 }); + document.dispatchEvent(new PointerEvent("pointerdown", withType("touch", 100, 200))); + document.dispatchEvent(new PointerEvent("pointermove", withType("touch", 140, 240))); + document.dispatchEvent(new PointerEvent("pointerup", withType("touch", 140, 240))); + check("a rejected pointer type draws nothing", api.list().length === 0, api.list()); + + document.dispatchEvent(new PointerEvent("pointerdown", withType("pen", 100, 200))); + document.dispatchEvent(new PointerEvent("pointermove", withType("pen", 140, 240))); + document.dispatchEvent(new PointerEvent("pointerup", withType("pen", 140, 240))); + check("the accepted one draws", api.list().length === 1, api.list()); + api.setOptions({ inkPointerTypes: null }); + + // --- a stroke extends its own path, it does not rebuild the page ------ + api.clear(); + api.setTool("highlight"); + selectRun(0); + api.mark(); + const settled = page1.querySelector("svg.an-m path"); + api.setTool("ink"); + document.dispatchEvent(new PointerEvent("pointerdown", at(100, 300))); + document.dispatchEvent(new PointerEvent("pointermove", at(140, 340))); + const live = page1.querySelector('svg[class="an"] path'); + document.dispatchEvent(new PointerEvent("pointermove", at(180, 300))); + // pointerup flushes; a background window never gives us the frame + document.dispatchEvent(new PointerEvent("pointerup", at(180, 300))); + check( + "the live path grows in place, in the node it started in", + live.isConnected && live.getAttribute("d").split(" L ").length === 3, + live.getAttribute("d") + ); + check("and what was already drawn is not rebuilt", settled.isConnected); + + // a second pointer must not extend the stroke it did not start + document.dispatchEvent(new PointerEvent("pointerdown", at(100, 400))); + const points = () => api.list()[api.list().length - 1].strokes[0].length; + const started = points(); + document.dispatchEvent( + new PointerEvent("pointermove", Object.assign(at(300, 400), { pointerId: 9 })) + ); + check("a second pointer does not extend the stroke", points() === started); + document.dispatchEvent(new PointerEvent("pointerup", at(100, 400))); + + api.clear(); api.setTool(null); const summary = document.createElement("div");