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-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, 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. */