From c92ff72f9c45f8f611b49ebf8418c56e7a0a8b65 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sun, 25 Oct 2026 22:02:16 +0200 Subject: [PATCH 1/2] Detect reference cycles per decoder 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 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 --- crates/pdf-document/src/page_tree.rs | 9 ++- crates/pdf-object-reader/src/context.rs | 2 +- crates/pdf-object-reader/src/decode.rs | 23 +++++++- crates/pdf-object-reader/src/error.rs | 16 +----- crates/pdf-object-reader/src/reader.rs | 76 +++++++------------------ 5 files changed, 54 insertions(+), 72 deletions(-) diff --git a/crates/pdf-document/src/page_tree.rs b/crates/pdf-document/src/page_tree.rs index fc374d01..6b1084c1 100644 --- a/crates/pdf-document/src/page_tree.rs +++ b/crates/pdf-document/src/page_tree.rs @@ -3,7 +3,7 @@ use pdf_object_reader::{ FromPdfObject, ObjectAccess, ObjectContext, ObjectReadError, ReadResult, pdf_array::PdfArray, }; -use std::{collections::HashMap, sync::Arc}; +use std::{any::TypeId, collections::HashMap, sync::Arc}; use crate::page::PdfPage; @@ -36,6 +36,13 @@ impl FromPdfObject for PageTree { ); Ok(Self { pages }) } + + /// The root is decoded as the same node its kids are, so a kid naming the root is + /// a cycle rather than a second copy of the tree. + #[inline] + fn cycle_key() -> TypeId { + PageTreeNode::cycle_key() + } } /// A `/Kids` entry of a page-tree node, dispatched on its `/Type`. diff --git a/crates/pdf-object-reader/src/context.rs b/crates/pdf-object-reader/src/context.rs index 2f41a6ef..d8e4c355 100644 --- a/crates/pdf-object-reader/src/context.rs +++ b/crates/pdf-object-reader/src/context.rs @@ -143,7 +143,7 @@ impl<'read, A: ObjectAccess + ?Sized> DictionaryContext<'read, A> { /// Resolves an entry's reference chain without entering its eager decoding scope. /// /// For an entry that may refer back to an object being decoded, whose eager read would - /// reenter the active path. + /// reenter an object still being decoded. pub fn resolve_entry(&mut self, key: &[u8]) -> ReadResult> { let Some(object) = self.dictionary.get(key) else { return Ok(None); diff --git a/crates/pdf-object-reader/src/decode.rs b/crates/pdf-object-reader/src/decode.rs index a27175ff..cd60e833 100644 --- a/crates/pdf-object-reader/src/decode.rs +++ b/crates/pdf-object-reader/src/decode.rs @@ -2,6 +2,7 @@ use num_traits::FromPrimitive; use pdf_graphics::{LineCap, LineJoin, MaskMode, TextRenderingMode}; +use std::any::TypeId; use crate::context::ObjectContext; use crate::error::{ObjectReadError, ReadResult}; @@ -22,8 +23,9 @@ use pdf_graphics::{color::Color, interval::Interval, rect::Rect, transform::Tran /// Implementations should use context reads for recursion rather than starting /// fresh sessions. /// -/// There are no ownership or thread-safety bounds on eager outputs. -/// Shared reading additionally requires `Self: Send + Sync + 'static`. +/// Outputs are `'static`, so the reader can tell decoders apart when it detects +/// cycles (see [`FromPdfObject::cycle_key`]). Shared reading additionally requires +/// `Self: Send + Sync`. /// /// Implementations must not retain strong pointers to other cached values as /// graph edges: store `ObjectHandle` for indirect edges to avoid ownership @@ -51,12 +53,22 @@ use pdf_graphics::{color::Color, interval::Interval, rect::Rect, transform::Tran /// let _ = reader.read_shared_indirect::(id); /// } /// ``` -pub trait FromPdfObject: Sized { +pub trait FromPdfObject: Sized + 'static { /// Converts the context's object, retaining its session for all nested reads. /// /// Return domain failures using `ObjectReadError::Decode`; shape and child /// failures propagate through `ReadResult`. fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult; + + /// Identifies this decoder when the reader detects cycles: an object a decoder is + /// still decoding cannot be entered again by a decoder with the same key. + /// + /// A decoder that decodes its object only through another decoder returns that + /// decoder's key, so reaching the object through either one is the same visit. + #[inline] + fn cycle_key() -> TypeId { + TypeId::of::() + } } macro_rules! value_decoder { @@ -120,6 +132,11 @@ impl FromPdfObject for Option { T::from_pdf_object(context).map(Some) } } + + #[inline] + fn cycle_key() -> TypeId { + T::cycle_key() + } } /// Decodes array elements in source order within the current traversal. diff --git a/crates/pdf-object-reader/src/error.rs b/crates/pdf-object-reader/src/error.rs index 2c41c44d..f28ff9d6 100644 --- a/crates/pdf-object-reader/src/error.rs +++ b/crates/pdf-object-reader/src/error.rs @@ -63,21 +63,11 @@ pub enum ObjectReadError { #[source] source: BoxedError, }, - /// Reference traversal revisited an object active in the current read. - #[error("cyclic reference encountered at {repeated:?}; active path: {path:?}")] + /// A decoder followed a reference back to an object it was still decoding. + #[error("cyclic reference encountered at {repeated:?}")] CyclicReference { - /// The object identifier repeated in the active path. + /// The object the same decoder was already decoding. repeated: ObjectId, - /// The active traversal path at the point the cycle was detected. - path: Vec, - }, - /// Reference traversal exceeded the configured depth limit. - #[error("reference depth exceeded maximum {maximum}; active path: {path:?}")] - ReferenceDepthExceeded { - /// The maximum permitted number of simultaneously active references. - maximum: usize, - /// The path whose next reference would exceed the limit. - path: Vec, }, /// Nested decoding, including direct containers, exceeded its depth limit. #[error("decoding depth exceeded maximum {maximum}")] diff --git a/crates/pdf-object-reader/src/reader.rs b/crates/pdf-object-reader/src/reader.rs index 6783f8fa..6a20db62 100644 --- a/crates/pdf-object-reader/src/reader.rs +++ b/crates/pdf-object-reader/src/reader.rs @@ -1,5 +1,6 @@ //! Reader orchestration and session-local traversal state. +use std::any::TypeId; use std::collections::HashSet; use std::num::NonZeroUsize; @@ -17,27 +18,18 @@ use crate::pdf_object::PdfObject; use crate::source::ObjectSource; use std::sync::Arc; -/// Bounds reference traversal and nested decoding, including direct containers. +/// Bounds nested decoding, including direct containers and reference hops. /// -/// Both limits default to 128. They count active stack depth, not total objects. +/// The limit defaults to 128. It counts active stack depth, not total objects. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ReadLimits { - max_reference_depth: NonZeroUsize, max_decode_depth: NonZeroUsize, } impl ReadLimits { - /// Configures independent limits for active references and decoder invocations. - pub fn new(max_reference_depth: NonZeroUsize, max_decode_depth: NonZeroUsize) -> Self { - Self { - max_reference_depth, - max_decode_depth, - } - } - - /// Returns the maximum number of simultaneously active indirect references. - pub fn max_reference_depth(self) -> NonZeroUsize { - self.max_reference_depth + /// Configures the limit on simultaneously active decoder invocations. + pub fn new(max_decode_depth: NonZeroUsize) -> Self { + Self { max_decode_depth } } /// Returns the maximum nesting of decoders, including direct array elements. @@ -48,10 +40,7 @@ impl ReadLimits { impl Default for ReadLimits { fn default() -> Self { - Self::new( - NonZeroUsize::new(128).unwrap_or(NonZeroUsize::MIN), - NonZeroUsize::new(128).unwrap_or(NonZeroUsize::MIN), - ) + Self::new(NonZeroUsize::new(128).unwrap_or(NonZeroUsize::MIN)) } } @@ -108,7 +97,7 @@ impl ObjectReader { &self.content_stream_ids } - /// Returns the reference and decoding depth limits. + /// Returns the decoding depth limit. pub fn limits(&self) -> ReadLimits { self.limits } @@ -117,7 +106,6 @@ impl ObjectReader { pub fn session(&self) -> ReadSession<'_, S, C> { ReadSession { reader: self, - active_path: Vec::new(), active_ids: HashSet::new(), decode_depth: 0, } @@ -203,10 +191,10 @@ pub trait ObjectAccess { /// decoding must use the read operations, which retain the scope. fn resolve(&mut self, object: &ObjectVariant) -> ReadResult; - /// Resolves and eagerly decodes within the current active path. + /// Resolves and eagerly decodes within the current traversal. fn read(&mut self, object: &ObjectVariant) -> ReadResult; - /// Acquires and eagerly decodes within the current active path. + /// Acquires and eagerly decodes within the current traversal. fn read_indirect(&mut self, id: ObjectId) -> ReadResult; /// Reads through typed caching without waiting on recursive or concurrent loads. @@ -222,7 +210,12 @@ pub trait ObjectAccess { /// Borrows a reader and holds the active state of one logical traversal. /// -/// An eager decoder's reference path remains active until it returns. Typed +/// An object an eager decoder reached by reference stays active until it returns. A cycle is a +/// decoder re-entering an object it is still decoding, compared by +/// [`FromPdfObject::cycle_key`]; another decoder may read that object, as when a +/// membership dictionary names itself in its own visibility expression. Each decoder +/// decodes an object the same way every time, so only the same decoder re-entering it +/// could recurse forever. Typed /// cache lookups are handled before eager cycle rejection, so recursive shared /// reads can return an already-published pending handle. Sibling visits are not /// cycles once their earlier traversal scope has ended. @@ -238,18 +231,12 @@ pub trait ObjectAccess { /// ``` pub struct ReadSession<'reader, S, C = ConcurrentObjectCache> { reader: &'reader ObjectReader, - active_path: Vec, - active_ids: HashSet, + /// The objects being decoded, each with the cycle key of the decoder reading it. + active_ids: HashSet<(ObjectId, TypeId)>, + /// The number of active decoder invocations, bounded by [`ReadLimits`]. decode_depth: usize, } -impl ReadSession<'_, S, C> { - /// Returns the current indirect-object path for diagnostics. - pub fn active_path(&self) -> &[ObjectId] { - &self.active_path - } -} - impl ObjectAccess for ReadSession<'_, S, C> where S: ObjectSource, @@ -292,21 +279,13 @@ where fn resolve(&mut self, object: &ObjectVariant) -> ReadResult { // Raw probes have chain-local tracking, so shared recursive edges can be inspected. - let mut path = Vec::new(); let mut ids = HashSet::new(); let mut object = PdfObject::new(object.clone()); while let ObjectVariant::Reference(id) = object.value() { let id = *id; if !ids.insert(id) { - return Err(ObjectReadError::CyclicReference { repeated: id, path }); + return Err(ObjectReadError::CyclicReference { repeated: id }); } - if path.len() >= self.reader.limits.max_reference_depth().get() { - return Err(ObjectReadError::ReferenceDepthExceeded { - maximum: self.reader.limits.max_reference_depth().get(), - path, - }); - } - path.push(id); object = self.load_object(id)?; } Ok(object) @@ -381,24 +360,13 @@ where // The child owns its traversal scope. Parent state remains intact on errors and unwinding. let mut child = ReadSession { reader: self.reader, - active_path: self.active_path.clone(), active_ids: self.active_ids.clone(), decode_depth: self.decode_depth.saturating_add(1), }; let object = loop { - if !child.active_ids.insert(id) { - return Err(ObjectReadError::CyclicReference { - repeated: id, - path: child.active_path, - }); - } - if child.active_path.len() >= child.reader.limits.max_reference_depth().get() { - return Err(ObjectReadError::ReferenceDepthExceeded { - maximum: child.reader.limits.max_reference_depth().get(), - path: child.active_path, - }); + if !child.active_ids.insert((id, T::cycle_key())) { + return Err(ObjectReadError::CyclicReference { repeated: id }); } - child.active_path.push(id); let object = child.load_object(id)?; match object.value() { ObjectVariant::Reference(next) => id = *next, From 1cf0329ed1d3eaf28d53b02be9d35cef44468012 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sun, 25 Oct 2026 22:02:16 +0200 Subject: [PATCH 2/2] Evaluate optional content visibility expressions 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 --- .../pdf-canvas/src/optional_content_filter.rs | 14 +-- crates/pdf-canvas/src/pdf_canvas.rs | 4 +- crates/pdf-optional-content/src/decode.rs | 5 +- crates/pdf-optional-content/src/lib.rs | 3 + .../src/optional_content.rs | 29 +++--- .../src/visibility_expression.rs | 89 +++++++++++++++++++ crates/pdf-web/js/annotation_contract.ts | 12 ++- 7 files changed, 127 insertions(+), 29 deletions(-) create mode 100644 crates/pdf-optional-content/src/visibility_expression.rs diff --git a/crates/pdf-canvas/src/optional_content_filter.rs b/crates/pdf-canvas/src/optional_content_filter.rs index c9f39ee7..cda295cb 100644 --- a/crates/pdf-canvas/src/optional_content_filter.rs +++ b/crates/pdf-canvas/src/optional_content_filter.rs @@ -6,7 +6,7 @@ use pdf_optional_content::{OptionalContentMembership, OptionalContentState}; /// Only the outermost hiding sequence is tracked: everything nested inside hidden /// content is hidden too, so inner sequences need no visibility lookup. pub(crate) struct OptionalContentFilter<'a> { - /// Group visibility the filter applies; at least one group is off. + /// Group visibility the filter applies. state: &'a OptionalContentState, /// Number of open marked-content sequences. depth: usize, @@ -15,14 +15,16 @@ pub(crate) struct OptionalContentFilter<'a> { } impl<'a> OptionalContentFilter<'a> { - /// Creates a filter for `state`, or `None` when every group is on and no content - /// can be hidden. - pub(crate) fn new(state: &'a OptionalContentState) -> Option { - state.hides_any().then_some(Self { + /// Creates a filter for `state`. + /// + /// Content can be hidden even when every group is on, by an `/AnyOff` or `/AllOff` + /// policy or a `/Not` visibility expression, so every state is filtered. + pub(crate) fn new(state: &'a OptionalContentState) -> Self { + Self { state, depth: 0, hidden_from: None, - }) + } } /// Returns a filter over the same state with no open sequences, for a nested canvas. diff --git a/crates/pdf-canvas/src/pdf_canvas.rs b/crates/pdf-canvas/src/pdf_canvas.rs index b620de80..f10ef32e 100644 --- a/crates/pdf-canvas/src/pdf_canvas.rs +++ b/crates/pdf-canvas/src/pdf_canvas.rs @@ -132,11 +132,9 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { } /// Hides content in marked-content sequences whose optional content is off in `state`. - /// - /// A state with every group on is not attached, so marked content costs nothing. #[must_use] pub fn with_optional_content(mut self, state: &'a OptionalContentState) -> Self { - self.optional_content = OptionalContentFilter::new(state); + self.optional_content = Some(OptionalContentFilter::new(state)); self } diff --git a/crates/pdf-optional-content/src/decode.rs b/crates/pdf-optional-content/src/decode.rs index 2930029e..75183326 100644 --- a/crates/pdf-optional-content/src/decode.rs +++ b/crates/pdf-optional-content/src/decode.rs @@ -122,7 +122,8 @@ impl FromPdfObject for OptionalContentMembership { // `/OCGs` is a single group or an array of them. groups: context.optional_list(b"OCGs")?, policy: context.optional(b"P")?.unwrap_or_default(), - has_visibility_expression: context.contains(b"VE"), + // A malformed expression falls back to `/OCGs` and `/P`. + visibility_expression: context.lenient(b"VE"), }), // A single group is a membership of that group under the default `AnyOn` policy. // A missing or invalid `/Type` is ignored and the dictionary read as a group. @@ -134,7 +135,7 @@ impl FromPdfObject for OptionalContentMembership { name.as_deref(), )?], policy: OptionalContentPolicy::AnyOn, - has_visibility_expression: false, + visibility_expression: None, }) } } diff --git a/crates/pdf-optional-content/src/lib.rs b/crates/pdf-optional-content/src/lib.rs index 94a33bec..f8ade85b 100644 --- a/crates/pdf-optional-content/src/lib.rs +++ b/crates/pdf-optional-content/src/lib.rs @@ -9,6 +9,8 @@ pub mod error; pub mod ocg_state; /// Optional content group visibility applied to page content and annotations. pub mod optional_content; +/// `/VE` visibility expressions of optional content membership dictionaries. +pub mod visibility_expression; mod wire; pub use error::OptionalContentError; @@ -18,3 +20,4 @@ pub use optional_content::{ OptionalContentGroupId, OptionalContentMembership, OptionalContentPolicy, OptionalContentProperties, OptionalContentState, }; +pub use visibility_expression::VisibilityExpression; diff --git a/crates/pdf-optional-content/src/optional_content.rs b/crates/pdf-optional-content/src/optional_content.rs index 168ccdf3..05d24e31 100644 --- a/crates/pdf-optional-content/src/optional_content.rs +++ b/crates/pdf-optional-content/src/optional_content.rs @@ -7,6 +7,7 @@ //! advances the document revision. use crate::error::OptionalContentError; use crate::ocg_state::{OcgStateEntry, OcgTarget}; +use crate::visibility_expression::VisibilityExpression; use std::collections::{BTreeMap, BTreeSet}; /// Identity of one optional content group within its source document. @@ -87,10 +88,9 @@ pub struct OptionalContentMembership { pub groups: Vec, /// The `/P` policy; `AnyOn` when the entry is absent. pub policy: OptionalContentPolicy, - /// Whether a `/VE` visibility expression was present. Expressions are not - /// evaluated, and content carrying one is treated as visible. Retaining the - /// flag lets a host report the limitation without decoding the entry again. - pub has_visibility_expression: bool, + /// The `/VE` visibility expression. When present it decides visibility in place + /// of `/OCGs` and `/P`. + pub visibility_expression: Option, } impl OptionalContentMembership { @@ -99,6 +99,10 @@ impl OptionalContentMembership { /// Callers use this to refresh only the pages a state change can affect. pub fn depends_on(&self, changed: &BTreeSet) -> bool { self.groups.iter().any(|id| changed.contains(id)) + || self + .visibility_expression + .as_ref() + .is_some_and(|expression| expression.depends_on(changed)) } } @@ -190,11 +194,13 @@ impl OptionalContentState { /// Whether content depending on `content` is currently displayed. /// - /// An `/OCMD` with no member groups imposes no restriction, and one carrying a - /// `/VE` visibility expression is treated as visible because the expression - /// takes precedence over `/OCGs` and is not evaluated here. + /// A `/VE` visibility expression takes precedence over `/OCGs` and `/P`; without + /// one, an `/OCMD` with no member groups imposes no restriction. pub fn is_visible(&self, content: &OptionalContentMembership) -> bool { - if content.has_visibility_expression || content.groups.is_empty() { + if let Some(expression) = &content.visibility_expression { + return expression.is_visible(self); + } + if content.groups.is_empty() { return true; } let mut groups = content.groups.iter().map(|id| self.group_visible(id)); @@ -242,14 +248,9 @@ impl OptionalContentState { changed } - /// Whether any tracked group is off; when none is, no content depends on this state. - pub fn hides_any(&self) -> bool { - self.visible.values().any(|visible| !visible) - } - /// Whether one group is on. An untracked group is visible, the safe default for /// a document whose `/OCProperties` never declared it. - fn group_visible(&self, id: &OptionalContentGroupId) -> bool { + pub(crate) fn group_visible(&self, id: &OptionalContentGroupId) -> bool { self.visible.get(id).copied().unwrap_or(true) } diff --git a/crates/pdf-optional-content/src/visibility_expression.rs b/crates/pdf-optional-content/src/visibility_expression.rs new file mode 100644 index 00000000..49096609 --- /dev/null +++ b/crates/pdf-optional-content/src/visibility_expression.rs @@ -0,0 +1,89 @@ +//! `/VE` visibility expressions of optional content membership dictionaries. +use crate::error::OptionalContentError; +use crate::optional_content::{OptionalContentGroupId, OptionalContentState}; +use pdf_object_reader::object_variant::ObjectVariant; +use pdf_object_reader::{ArrayContext, FromPdfObject, ObjectAccess, ObjectContext, ReadResult}; +use std::collections::BTreeSet; +use std::sync::Arc; + +/// An `/OCMD` `/VE` visibility expression combining group states with boolean operators. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export_to = "annotation_contract.ts"))] +pub enum VisibilityExpression { + /// The state of one group. A membership dictionary named as an operand is looked up + /// as a group, never evaluated as a membership. + Group(OptionalContentGroupId), + /// `/Not`: the negation of its single operand. + Not(Box), + /// `/And`: true when every operand is. + And(Vec), + /// `/Or`: true when any operand is. + Or(Vec), +} + +impl VisibilityExpression { + /// Whether any group this expression reads appears in `changed`. + pub fn depends_on(&self, changed: &BTreeSet) -> bool { + match self { + Self::Group(id) => changed.contains(id), + Self::Not(operand) => operand.depends_on(changed), + Self::And(operands) | Self::Or(operands) => { + operands.iter().any(|operand| operand.depends_on(changed)) + } + } + } + + /// Evaluates the expression against `state`. An `/And` or `/Or` without operands + /// is false. + pub(crate) fn is_visible(&self, state: &OptionalContentState) -> bool { + match self { + Self::Group(id) => state.group_visible(id), + Self::Not(operand) => !operand.is_visible(state), + Self::And(operands) => { + !operands.is_empty() && operands.iter().all(|operand| operand.is_visible(state)) + } + Self::Or(operands) => operands.iter().any(|operand| operand.is_visible(state)), + } + } +} + +/// Decodes a `/VE` visibility expression: an operator array or a group dictionary. +impl FromPdfObject for VisibilityExpression { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + // Only an expression is an array; any other operand names an optional content group. + if !matches!(context.object(), ObjectVariant::Array(_)) { + return Ok(Self::Group(context.decode()?)); + } + let mut context = context.array()?; + // An expression is `[operator operand ...]`: the leading name selects the operator + // and every element after it is one of its operands. + let Some((operator, operands)) = context.array().split_first() else { + return Err(OptionalContentError::InvalidEntry { + entry: b"VE", + reason: "empty visibility expression".to_owned(), + } + .into()); + }; + let operator = context.read::>(operator)?; + let operands = ArrayContext::new(operands, context.access()).read_all::()?; + match &*operator { + b"And" => Ok(Self::And(operands)), + b"Or" => Ok(Self::Or(operands)), + // `/Not` negates exactly one operand. + b"Not" => match <[Self; 1]>::try_from(operands) { + Ok([operand]) => Ok(Self::Not(Box::new(operand))), + Err(_) => Err(OptionalContentError::InvalidEntry { + entry: b"VE", + reason: "/Not takes exactly one operand".to_owned(), + } + .into()), + }, + other => Err(OptionalContentError::InvalidEntry { + entry: b"VE", + reason: format!("unsupported visibility expression operator '{other:?}'"), + } + .into()), + } + } +} diff --git a/crates/pdf-web/js/annotation_contract.ts b/crates/pdf-web/js/annotation_contract.ts index 3f72038a..5ec57463 100644 --- a/crates/pdf-web/js/annotation_contract.ts +++ b/crates/pdf-web/js/annotation_contract.ts @@ -1176,11 +1176,10 @@ groups: Array, */ policy: OptionalContentPolicy, /** - * Whether a `/VE` visibility expression was present. Expressions are not - * evaluated, and content carrying one is treated as visible. Retaining the - * flag lets a host report the limitation without decoding the entry again. + * The `/VE` visibility expression. When present it decides visibility in place + * of `/OCGs` and `/P`. */ -has_visibility_expression: boolean, }; +visibility_expression: VisibilityExpression | null, }; /** * The `/P` visibility policy of an `/OCMD` membership dictionary. @@ -1774,6 +1773,11 @@ field_type: Array, */ value: WidgetFieldValue | null, }; +/** + * An `/OCMD` `/VE` visibility expression combining group states with boolean operators. + */ +export type VisibilityExpression = { "Group": OptionalContentGroupId } | { "Not": VisibilityExpression } | { "And": Array } | { "Or": Array }; + /** * One projected entry plus its DOM placement. */