Skip to content

fix(pdf): answer whether a file can be annotated, and retire the plan - #851

Merged
andiwand merged 2 commits into
mainfrom
docs/retire-pdf-annotation-plan
Sep 6, 2026
Merged

fix(pdf): answer whether a file can be annotated, and retire the plan#851
andiwand merged 2 commits into
mainfrom
docs/retire-pdf-annotation-plan

Conversation

@andiwand

@andiwand andiwand commented Sep 6, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

The close-out for #843#850. Rebased onto main; four things.

1. A capability that lied

DecodedFile::capabilities().annotate was true for every pdf, while annotate() throws for a file IncrementalWriter refuses. A mobile UI keying its annotate button off it gets a button that throws. Found by the other session sweeping the public corpus.

The sharp part is that the obvious fix — narrowing by encryption_state(), as translate_html does — would not have worked. Casio_WVA-M650-7AJF.pdf is owner-locked: the empty password opens it, so odr reports it not_encrypted and password_encrypted() == false, yet the /Encrypt is still there and its new objects would need a key the parser deliberately never keeps.

So, as you suggested, a counterpart to Document::is_editable:

bool PdfFile::is_annotatable() const noexcept;

FileTypeCapabilities answers for the format; this answers for the file in hand, and capabilities().annotate is narrowed by it. Bound in all four bindings, as is_editable is.

Both conditions are settled in the construction-time probe, where the DocumentParser that knows them is already in hand — no second parse. Verified:

file is_annotatable capability
style-various-1.pdf true true
Casio_WVA-M650-7AJF.pdf (owner-locked) false false
a pdf with a broken startxref false false

2. The reference-output pins

#849 put data-odr-page and data-odr-space on every pdf page and two files in resources/, so main emits html the pinned references do not have.

odr-public 27 files, odr-private 802, all under output/pdf/, plus the two resources in each; nothing added or removed. I checked all 829 diffs line by line — allowing only the old bare <div class="p …">, the new one with the attributes, the stylesheet link and the script tag, zero files have any other differing line. Output commits 19392df / ee3077e on their main; a fresh local run is now byte-identical to both.

3. The design doc becomes a record

Phase list, day estimates and line counts go — git and the PRs hold that. What survives is what someone changing this next needs: the six decisions and the alternatives they beat, the wire format, the objects a highlight writes. Plus two things that cost more than the estimate said and neither was found by a test: mix-blend-mode inside an svg composites against that svg's own canvas rather than the page, and selectionchange fires on every character a drag covers.

4. pdf/AGENTS.md caught up

It still opened "Goal. Faithful read-only HTML" and had no pdf_writer/pdf_annotation rows.

Interop, finished

Six engines read what we write: ghostscript, PDFium, CoreGraphics, pdf.js (added here — every subtype, quad count, colour, author and contents correct, and it renders the highlight with the text showing through), qpdf's structural check, and our own renderer. Acrobat is the one gap, and the doc says so rather than implying it was covered — it is not installed here.

Verified

1533 C++ tests (1527 pass, 6 pre-existing skips), JNI 56, python 75, wasm 42, the Apple slice compiles; -Werror clean.

The design doc becomes a record: the decisions and the alternatives they beat,
which is what a reader changing this next needs, and a short account of how it
landed. The phase list, the day estimates and the line counts go — git and the
pull requests hold that.

Two things cost more than the estimate said and neither was found by a test, so
both are written down: a `mix-blend-mode` inside an svg composites against that
svg's own canvas rather than the page, and `selectionchange` fires on every
character a drag covers.

`pdf/AGENTS.md` no longer calls the module read-only, and its module table has
the two files that write.

Advances the reference-output pins for #849, which put `data-odr-page` and
`data-odr-space` on every pdf page and two files in `resources/`. 829 files
changed; no line differs beyond those attributes, the stylesheet link and the
script tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz
@andiwand
andiwand force-pushed the docs/retire-pdf-annotation-plan branch from 913d553 to 9e4b0e0 Compare September 6, 2026 16:30
@andiwand andiwand changed the title docs(pdf): retire the annotation plan, its phases having landed fix(pdf): answer whether a file can be annotated, and retire the plan Sep 6, 2026
`capabilities().annotate` was true for every pdf, so a caller keying a button
off it got one that throws: `IncrementalWriter` refuses a file whose
cross-reference table was rebuilt, and one declaring an `/Encrypt`.

`PdfFile::is_annotatable` is the counterpart of `Document::is_editable` —
`FileTypeCapabilities` answers for the format, this for the file in hand — and
`DecodedFile::capabilities()` narrows `annotate` by it. Bound in all four
bindings, as `is_editable` is.

Neither condition is `encryption_state()`. An owner-locked pdf opens with the
empty password and reports itself *unencrypted*, yet its `/Encrypt` still stops
us appending, so the answer is settled during the construction-time probe,
where the parser that knows both is already in hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz
@andiwand
andiwand force-pushed the docs/retire-pdf-annotation-plan branch from 9e4b0e0 to 833ddba Compare September 6, 2026 16:34
@andiwand
andiwand merged commit 6398749 into main Sep 6, 2026
10 checks passed
@andiwand
andiwand deleted the docs/retire-pdf-annotation-plan branch September 6, 2026 16:34
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