Skip to content

feat(pdf): write markup and ink annotations - #847

Merged
andiwand merged 2 commits into
mainfrom
feat/pdf-annotation-writers
Sep 6, 2026
Merged

feat(pdf): write markup and ink annotations#847
andiwand merged 2 commits into
mainfrom
feat/pdf-annotation-writers

Conversation

@andiwand

@andiwand andiwand commented Sep 6, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Phases 2 and 3 of docs/design/pdf-annotation.md, on top of the writer from #845. First PR of four; the public API, browser layer and bindings follow.

pdf/pdf_annotation.{hpp,cpp}:

  • write_text_markup/Highlight, /Underline, /StrikeOut, /Squiggly
  • write_ink/Ink, strokes smoothed Catmull-Rom → the cubic beziers PDF has
  • append_page_annotations — puts them on a page, rewriting the /Annots array itself where the page holds it indirectly rather than rewriting the page

Every annotation carries its own appearance stream (decision 3): our renderer draws annotations only from /AP /N, and viewers disagree about the ones they synthesize. Only the highlight blends Multiply — it is a wash over the text, where the others are marks drawn on top of it (11.6.4.1).

/QuadPoints ordering — settled

The doc has carried this as unverified since the spike, because an annotation with an /AP never has its quad points read, so no oracle we had could test the ordering.

Resolved by writing two files with the same visual rectangle and no appearance, forcing the viewer to synthesize one:

order ghostscript renders
upper-left, upper-right, lower-left, lower-right a clean rectangle ✅
the spec's counterclockwise order (12.5.6.10) a twisted, smeared blob ❌

So the Z-order every implementation writes is right and the spec's literal order is wrong, and the comment in the code now says so with evidence behind it. Worth knowing for anyone repeating this: CoreGraphics synthesizes no appearance at all, so it is no oracle for this question.

Verified

Eight assertion tests: round trips for highlight and ink, all four markup subtypes, the blend-mode split, /Rect as the union of quads, the ink box grown by the stroke width, /T and /Contents omitted when empty (and a ( in /Contents surviving escaping), several annotations sharing one page rewrite, and appending to style-various-1.pdf's existing /Annots.

End to end on a real file with all five kinds at once: qpdf --check clean, CoreGraphics paints every one with the text showing through the highlight, and odr's own renderer emits the highlight as <path fill="rgb(255,230,51)" style="mix-blend-mode:multiply"> and the ink as C beziers — the self-verifying round trip the design predicted.

18 annotation/writer tests pass; -Werror clean; clang-tidy clean.

No consumer-visible change yet — the public API is the next PR — so no changelog entry.

`write_text_markup` for `/Highlight`, `/Underline`, `/StrikeOut` and
`/Squiggly`, `write_ink` for `/Ink`, and `append_page_annotations` to put them
on a page — rewriting the `/Annots` array itself where the page holds it
indirectly. Each carries the appearance stream it paints through, since our own
renderer draws annotations only from `/AP /N` and viewers disagree on the ones
they synthesize.

Only the highlight blends Multiply: it is a wash over the text where the others
are marks on top of it (11.6.4.1). Ink strokes are smoothed Catmull-Rom into
the cubic beziers PDF has.

The quad order is upper-left, upper-right, lower-left, lower-right. 12.5.6.10
says counterclockwise; rendered without an appearance, that order comes out a
twisted quad and this one comes out a rectangle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz
`/CA` already applies to an annotation's appearance as a whole (12.5.2), and
our own extractor composites it as a group — so the `ca`/`CA` the appearance
stream set in its own `/ExtGState` applied the opacity a second time. The state
now carries only the highlight's `/BM /Multiply`, and nothing else needs one.

Also binds the header-declared functions to `odr::internal`, folds the two
`box_of` overloads into one, and drops the lambda parameter the test never
used, which broke the `-Werror` builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147n68S7LNAv9KynLaGgXN4
@andiwand
andiwand merged commit f1af7d0 into main Sep 6, 2026
25 checks passed
@andiwand
andiwand deleted the feat/pdf-annotation-writers branch September 6, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant