Skip to content

Evaluate optional content visibility expressions - #609

Merged
Velli20 merged 2 commits into
mainfrom
evaluate-visibility-expressions
Oct 25, 2026
Merged

Velli20 merged 2 commits into
mainfrom
evaluate-visibility-expressions

Conversation

@Velli20

@Velli20 Velli20 commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

An /OCMD with a /VE visibility expression was always treated as visible. In the repro, an 8pt black frame drawn under
/VE [/And 20 0 R [/Not 30 0 R] [/Not 40 0 R]] (groups 30 and 40 on, so hidden) was rendered around the page images, while PDFium hides it.

Optional content

  • /VE decodes into a new VisibilityExpression (Group, Not, And, Or) in its own module, replacing has_visibility_expression. When present it decides visibility in place of /OCGs and /P; a malformed expression falls back to them.
  • depends_on walks the expression so SetOCGState refreshes dependent pages.
  • The canvas attached its optional content filter only when some group was off. /Not and the /AnyOff//AllOff policies hide content while every group is on, so the filter is now always attached.
  • TypeScript annotation contract regenerated.

Reader: cycles are detected per decoder
The /OCMD names itself (20 0 R) inside its own /VE. The reader rejected any re-entry of an object being decoded, so this read failed and the expression was dropped. A decoder decodes an object the same way every time, so only the same decoder re-entering an object can recurse forever:

  • The active set holds (object, cycle key) pairs. FromPdfObject::cycle_key() defaults to the decoder's TypeId (FromPdfObject is now 'static; every implementation already was).
  • Option<T> and PageTree decode their object only through another decoder and forward its key, so a self-referencing /SMask and a kid naming the page tree root are still cycles.
  • The ordered active_path is removed: its reference-depth limit duplicated decode_depth, which counts every reference hop, and nothing read the path. ReadLimits keeps only the decode depth; CyclicReference keeps only repeated; ReferenceDepthExceeded is removed.

Test plan

  • cargo test --workspace --exclude pdf-conformance --exclude pdf-graphics-skia: 1067 passed (the excluded crates need a Skia binary download that was unavailable locally)
  • cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check, cargo xtask annotation-types --check
  • Repro render through the Skia replay: frame hidden, matching PDFium (10 replayed commands, was 11)
  • document_open A/B against main, 5 interleaved rounds, shipped release profile, for the per-decoder cycle key: all eight documents within −1.3%…+0.4% (noise)
  • Same A/B after removing active_path: medians across rounds within −2.3%…+1.5% with mixed signs (noise; one webgl.pdf round outlier at 126.7 ms, the others 107–109 ms)

🤖 Generated with Claude Code

Velli20 and others added 2 commits October 25, 2026 22:02
A read was rejected as cyclic whenever it reached an object already
being decoded, whatever decoder asked for it. An /OCMD that names
itself in its own /VE visibility expression is read once as a
membership and once as a group, which cannot recurse, yet the second
read failed and the whole expression was dropped.

The active set now holds (object, cycle key) pairs. A decoder decodes
an object the same way every time, so only the same decoder re-entering
an object can recurse forever. FromPdfObject gains cycle_key(),
defaulting to the decoder's TypeId; this needs FromPdfObject: 'static,
which every implementation already met. Option<T> and PageTree decode
their object only through another decoder and forward that decoder's
key, so reaching an object through them is still the same visit; a
kid naming the page tree root remains a cycle.

The ordered active path is gone. Its reference-depth limit duplicated
decode_depth, which already counts every reference hop, and following
a reference chain is iterative and stopped by the cycle check. Nothing
read the path from errors or the active_path() accessor. ReadLimits
keeps only the decode depth, CyclicReference keeps only the repeated
object, and ReferenceDepthExceeded is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An /OCMD with a /VE visibility expression was always treated as
visible, so content it hides was drawn.

/VE now decodes into a VisibilityExpression (Group, Not, And, Or) in
its own module, replacing the has_visibility_expression flag on
OptionalContentMembership. When present it decides visibility in place
of /OCGs and /P; an /And or /Or without operands is false. A malformed
expression falls back to /OCGs and /P. depends_on also walks the
expression so SetOCGState refreshes pages that depend on it.

The canvas attached its optional content filter only when some group
was off, but /Not expressions and the /AnyOff and /AllOff policies
hide content while every group is on, so the filter is now attached
for every state.

The TypeScript annotation contract is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Conformance reads

Documents that Safe-PDF reads differently from the base commit, compared with PDFium. A read
regresses when Safe-PDF fails to open a document it opened before, crashes or times out, or
counts a different number of pages than PDFium.

pdf.js corpus

1009 documents compared with 73960a2: 0 read worse, 2 read better.

Improvements

Document Base This pull request
issue17190_1 timeout: time limit exceeded (stage safe-read) read, 1 page
issue19517 timeout: time limit exceeded (stage safe-read) read, 1 page

840 documents of the PDFium corpus read as on 73960a2.

Details are in the job summaries of the workflow run. Rerun a case locally with
cargo conformance run --corpus <corpus> --read-only --case <id>.

@Velli20
Velli20 merged commit 77bd808 into main Oct 25, 2026
10 checks passed
@Velli20
Velli20 deleted the evaluate-visibility-expressions branch October 25, 2026 20:23
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