From 823519d468bbaf8fbcc2e12f37de71b2e81fbcfe Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sun, 18 Oct 2026 21:08:38 +0300 Subject: [PATCH] Hide page content in optional content groups that are turned off Marked-content sequences tagged /OC were painted regardless of the document's layer settings, so content in a group listed under the default configuration's /OFF still rendered. Page content now honours optional content: - BDC keeps its property-list name, and Resources reads the optional content entries of /Properties. - The canvas gets an OptionalContentFilter that hides painting and text recording inside sequences whose group or membership is off. It only exists when the document turns some group off, so other documents pay nothing for marked content. - PdfRenderer and the web page renderer pass the visibility state, the web renderer sharing the annotation overlay's state. The optional content model moves out of pdf-annotation-core into a new pdf-optional-content crate, since rendering now depends on it. Its decoders are all FromPdfObject impls. A single /OCG reference is represented as a one-group /AnyOn membership, which collapses the former AnnotationOptionalContent enum into OptionalContentMembership. pdf-object-reader gains DictionaryContext::lenient_entries and optional_list for mixed namespaces and single-or-array entries. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 16 ++ crates/pdf-annotation-core/Cargo.toml | 3 +- crates/pdf-annotation-core/src/error.rs | 14 ++ crates/pdf-annotation-core/src/lib.rs | 8 - crates/pdf-annotation-core/src/overlay.rs | 8 +- crates/pdf-annotation-core/src/pdf_data.rs | 8 +- crates/pdf-annotation-core/src/projection.rs | 2 +- .../pdf-annotation-core/src/source_decode.rs | 6 +- .../src/source_decode_actions.rs | 2 +- .../src/source_decode_optional_content.rs | 192 ------------------ crates/pdf-canvas/Cargo.toml | 1 + .../src/canvas_marked_content_ops.rs | 23 ++- crates/pdf-canvas/src/canvas_text_ops.rs | 3 +- crates/pdf-canvas/src/lib.rs | 2 + .../pdf-canvas/src/optional_content_filter.rs | 83 ++++++++ crates/pdf-canvas/src/pdf_canvas.rs | 38 ++++ .../src/marked_content_operators.rs | 17 +- .../src/pdf_operator_backend.rs | 8 +- .../src/recording_pdf_operator_backend.rs | 12 +- crates/pdf-document/Cargo.toml | 1 + crates/pdf-document/src/catalog.rs | 2 +- crates/pdf-document/src/document.rs | 2 +- crates/pdf-object-reader/src/context.rs | 41 ++++ crates/pdf-optional-content/Cargo.toml | 16 ++ crates/pdf-optional-content/src/decode.rs | 124 +++++++++++ crates/pdf-optional-content/src/error.rs | 35 ++++ crates/pdf-optional-content/src/lib.rs | 20 ++ .../src/ocg_state.rs | 8 +- .../src/optional_content.rs | 109 +++++----- crates/pdf-optional-content/src/wire.rs | 26 +++ crates/pdf-renderer/Cargo.toml | 1 + crates/pdf-renderer/src/lib.rs | 29 ++- crates/pdf-resources/Cargo.toml | 1 + crates/pdf-resources/src/resources.rs | 18 ++ crates/pdf-web/Cargo.toml | 1 + crates/pdf-web/js/annotation_contract.ts | 20 +- crates/pdf-web/src/annotation_overlay.rs | 6 + crates/pdf-web/src/page_renderer.rs | 1 + 38 files changed, 602 insertions(+), 305 deletions(-) delete mode 100644 crates/pdf-annotation-core/src/source_decode_optional_content.rs create mode 100644 crates/pdf-canvas/src/optional_content_filter.rs create mode 100644 crates/pdf-optional-content/Cargo.toml create mode 100644 crates/pdf-optional-content/src/decode.rs create mode 100644 crates/pdf-optional-content/src/error.rs create mode 100644 crates/pdf-optional-content/src/lib.rs rename crates/{pdf-annotation-core => pdf-optional-content}/src/ocg_state.rs (91%) rename crates/{pdf-annotation-core => pdf-optional-content}/src/optional_content.rs (77%) create mode 100644 crates/pdf-optional-content/src/wire.rs diff --git a/Cargo.lock b/Cargo.lock index c189b17f..99cb2dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2082,6 +2082,7 @@ dependencies = [ "num-traits", "pdf-graphics", "pdf-object-reader", + "pdf-optional-content", "serde", "serde_json", "thiserror 2.0.12", @@ -2104,6 +2105,7 @@ dependencies = [ "pdf-graphics", "pdf-image", "pdf-object-reader", + "pdf-optional-content", "pdf-resources", "pdf-shading", "pdf-text-engine", @@ -2222,6 +2224,7 @@ dependencies = [ "pdf-graphics", "pdf-object-collection", "pdf-object-reader", + "pdf-optional-content", "pdf-parser", "pdf-resources", "pdf-utils", @@ -2408,6 +2411,16 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "pdf-optional-content" +version = "0.1.0" +dependencies = [ + "pdf-object-reader", + "serde", + "thiserror 2.0.12", + "ts-rs", +] + [[package]] name = "pdf-parser" version = "0.1.0" @@ -2439,6 +2452,7 @@ dependencies = [ "pdf-document", "pdf-font", "pdf-graphics", + "pdf-optional-content", "pdf-text-engine", "pdf-utils", "thiserror 2.0.12", @@ -2460,6 +2474,7 @@ dependencies = [ "pdf-image", "pdf-object-collection", "pdf-object-reader", + "pdf-optional-content", "pdf-shading", "thiserror 2.0.12", ] @@ -2517,6 +2532,7 @@ dependencies = [ "pdf-font", "pdf-graphics", "pdf-graphics-web", + "pdf-optional-content", "pdf-renderer", "pdf-text-engine", "serde", diff --git a/crates/pdf-annotation-core/Cargo.toml b/crates/pdf-annotation-core/Cargo.toml index fe04f9a6..206eaffa 100644 --- a/crates/pdf-annotation-core/Cargo.toml +++ b/crates/pdf-annotation-core/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" workspace = true [features] -typescript = ["dep:ts-rs", "pdf-graphics/typescript"] +typescript = ["dep:ts-rs", "pdf-graphics/typescript", "pdf-optional-content/typescript"] [dependencies] bitflags = "2.9.1" @@ -18,3 +18,4 @@ serde_json = "1" thiserror = "2.0.12" pdf-graphics = { path = "../pdf-graphics", features = ["serde"] } pdf-object-reader = { path = "../pdf-object-reader" } +pdf-optional-content = { path = "../pdf-optional-content" } diff --git a/crates/pdf-annotation-core/src/error.rs b/crates/pdf-annotation-core/src/error.rs index 6fd6349f..11b2322c 100644 --- a/crates/pdf-annotation-core/src/error.rs +++ b/crates/pdf-annotation-core/src/error.rs @@ -27,6 +27,20 @@ pub enum SourceDecodeError { ResourceLimit, } +impl From for SourceDecodeError { + fn from(error: pdf_optional_content::OptionalContentError) -> Self { + use pdf_optional_content::OptionalContentError; + match error { + OptionalContentError::Object(error) => Self::Object(error), + OptionalContentError::InvalidEntry { entry, reason } => { + Self::InvalidEntry { entry, reason } + } + OptionalContentError::MissingEntry { entry } => Self::MissingEntry { entry }, + OptionalContentError::ResourceLimit => Self::ResourceLimit, + } + } +} + impl From for pdf_object_reader::ObjectReadError { fn from(source: SourceDecodeError) -> Self { Self::Decode { diff --git a/crates/pdf-annotation-core/src/lib.rs b/crates/pdf-annotation-core/src/lib.rs index 879f4962..971cc2a8 100644 --- a/crates/pdf-annotation-core/src/lib.rs +++ b/crates/pdf-annotation-core/src/lib.rs @@ -29,10 +29,6 @@ pub mod free_text; pub mod kind; /// Source snapshots and live annotation capabilities. pub mod metadata; -/// Optional content group state retained from `SetOCGState` actions. -pub mod ocg_state; -/// Optional content group visibility applied to annotation presentation. -pub mod optional_content; /// Owned source PDF annotation types, independent of parser resources. pub mod pdf_data; /// Canonical native PDF annotation record and subtype vocabulary. @@ -50,7 +46,6 @@ mod validation; mod source_decode; mod source_decode_actions; -mod source_decode_optional_content; mod source_decode_subtypes; mod source_decode_widget; /// Shared fields normalized from the widget records that display them. @@ -78,9 +73,6 @@ mod widget_control; pub use entry::{AnnotationEntry, ControlKind, ControlTag, WidgetControl}; pub use layer_error::{AnnotationLayerError, AnnotationLayerResult}; -pub use optional_content::{ - AnnotationOptionalContent, OptionalContentProperties, OptionalContentState, -}; pub use overlay::{AnnotationOverlay, PreparedAnnotations}; pub use requests::{ AnnotationCommandRequest, AnnotationReceipt, AnnotationTarget, OptionalContentReceipt, diff --git a/crates/pdf-annotation-core/src/overlay.rs b/crates/pdf-annotation-core/src/overlay.rs index 5e8b2442..164b2574 100644 --- a/crates/pdf-annotation-core/src/overlay.rs +++ b/crates/pdf-annotation-core/src/overlay.rs @@ -8,8 +8,6 @@ use crate::{ kind::AnnotationKind, layer_error::{AnnotationLayerError, AnnotationLayerResult}, models::{Annotation, AnnotationId, Point, Revision}, - ocg_state::OcgStateEntry, - optional_content::{OptionalContentProperties, OptionalContentState}, pdf_data::{AnnotationAction, SourceAnnotation}, projection, requests::{ @@ -18,6 +16,7 @@ use crate::{ }; use pdf_graphics::{rect::Rect, viewport::PageViewport}; use pdf_object_reader::diagnostic::PdfReadDiagnostic; +use pdf_optional_content::{OcgStateEntry, OptionalContentProperties, OptionalContentState}; use std::{ collections::{BTreeMap, BTreeSet}, sync::Arc, @@ -102,6 +101,11 @@ impl AnnotationOverlay { }) } + /// Returns the runtime optional content visibility shared with page content. + pub fn optional_content(&self) -> &OptionalContentState { + &self.optional_content + } + /// Returns import diagnostics ordered by page and object; does not mutate state. pub fn diagnostics(&self) -> &[PdfReadDiagnostic] { &self.document.diagnostics diff --git a/crates/pdf-annotation-core/src/pdf_data.rs b/crates/pdf-annotation-core/src/pdf_data.rs index 6ed7c5af..711a0345 100644 --- a/crates/pdf-annotation-core/src/pdf_data.rs +++ b/crates/pdf-annotation-core/src/pdf_data.rs @@ -1,10 +1,8 @@ //! Owned PDF metadata. These values retain source information without resolving resources. //! Raw byte fields are never interpreted as UTF-8 merely to serialize them. -use crate::{ - metadata::AnnotationFlags, models::Quad, ocg_state::OcgStateEntry, - optional_content::AnnotationOptionalContent, -}; +use crate::{metadata::AnnotationFlags, models::Quad}; use pdf_graphics::{DashPattern, color::Color, polyline::Polyline, rect::Rect}; +use pdf_optional_content::{OcgStateEntry, OptionalContentMembership}; bitflags::bitflags! { /// Widget form field flags parsed from the inherited `/Ff` entry. @@ -894,7 +892,7 @@ pub struct SourceAnnotation { /// Defaulted on load so sidecars written before this entry still deserialize. #[serde(default)] #[cfg_attr(feature = "typescript", ts(optional))] - pub optional_content: Option, + pub optional_content: Option, /// The parsed subtype-specific payload. pub kind: NativeAnnotation, } diff --git a/crates/pdf-annotation-core/src/projection.rs b/crates/pdf-annotation-core/src/projection.rs index 0c6eba7d..66414941 100644 --- a/crates/pdf-annotation-core/src/projection.rs +++ b/crates/pdf-annotation-core/src/projection.rs @@ -7,11 +7,11 @@ use crate::{ kind::AnnotationKind as Kind, layer_error::{AnnotationLayerError, AnnotationLayerResult}, models::{Annotation, Revision}, - optional_content::OptionalContentState, pdf_data::AnnotationAction, shapes, widget_control, }; use pdf_graphics::{point::Point, rect::Rect, size::Size, viewport::PageViewport}; +use pdf_optional_content::OptionalContentState; /// The shared field a widget is bound to; `None` for other kinds. fn widget_field<'a>( diff --git a/crates/pdf-annotation-core/src/source_decode.rs b/crates/pdf-annotation-core/src/source_decode.rs index 039123ae..c6c3eee5 100644 --- a/crates/pdf-annotation-core/src/source_decode.rs +++ b/crates/pdf-annotation-core/src/source_decode.rs @@ -19,13 +19,15 @@ use pdf_object_reader::{ object_resolver::{DanglingAsNullResolver, ObjectResolver}, object_variant::ObjectVariant, }; +use pdf_optional_content::OptionalContentMembership; pub(crate) type DecodeResult = Result; impl FromPdfObject for SourceAnnotation { fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { - let context = context.dictionary()?; + let mut context = context.dictionary()?; let dictionary = context.dictionary(); + let optional_content = context.optional::(b"OC")?; // Annotations often carry stale references, which read as null (ISO 32000 ยง7.3.10). let objects = &DanglingAsNullResolver(context.source()); // Some PDFs omit `/Type` on annotation dictionaries even though the @@ -85,8 +87,6 @@ impl FromPdfObject for SourceAnnotation { .map(|value| value.try_bytes(objects).map(Vec::from)) .transpose()?; let struct_parent = dictionary.optional_number::(b"StructParent", objects)?; - let optional_content = - crate::source_decode_optional_content::optional_content(dictionary, objects)?; let border = AnnotationBorder::from_dictionary(dictionary, objects)?; let color = color(dictionary, b"C", objects)?; diff --git a/crates/pdf-annotation-core/src/source_decode_actions.rs b/crates/pdf-annotation-core/src/source_decode_actions.rs index 12dd8994..126bf5f5 100644 --- a/crates/pdf-annotation-core/src/source_decode_actions.rs +++ b/crates/pdf-annotation-core/src/source_decode_actions.rs @@ -1,6 +1,5 @@ //! Action, destination, and file-specification decoding; nothing is executed. use crate::error::SourceDecodeError; -use crate::ocg_state::{OcgStateEntry, OcgTarget}; use crate::pdf_data::{ AnnotationAction, AnnotationDestination, DestinationTarget, ExplicitDestination, FileSpecification, FileSpecificationDictionary, @@ -10,6 +9,7 @@ use pdf_object_reader::{ dictionary::Dictionary, object_error::ObjectError, object_lookup::ObjectLookupExt, object_resolver::ObjectResolver, object_variant::ObjectVariant, }; +use pdf_optional_content::{OcgStateEntry, OcgTarget}; impl AnnotationAction { pub(crate) fn from_dictionary( diff --git a/crates/pdf-annotation-core/src/source_decode_optional_content.rs b/crates/pdf-annotation-core/src/source_decode_optional_content.rs deleted file mode 100644 index 865b2fcf..00000000 --- a/crates/pdf-annotation-core/src/source_decode_optional_content.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Decodes optional content declarations into owned Core data. -//! -//! Group resources are never fetched beyond the `/Name` needed to identify a group -//! that has no object number, preserving reference identity the way -//! [`crate::ocg_state`] does for `SetOCGState` targets. -use crate::error::SourceDecodeError; -use crate::optional_content::{ - AnnotationOptionalContent, OptionalContentBaseState, OptionalContentConfiguration, - OptionalContentGroup, OptionalContentGroupId, OptionalContentMembership, OptionalContentPolicy, - OptionalContentProperties, -}; -use crate::source_decode::DecodeResult; -use pdf_object_reader::{ - Dictionary, FromPdfObject, ObjectAccess, ObjectContext, ReadResult, - object_lookup::ObjectLookupExt, object_resolver::ObjectResolver, object_variant::ObjectVariant, -}; - -impl FromPdfObject for OptionalContentProperties { - fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { - let context = context.dictionary()?; - let dictionary = context.dictionary(); - let objects = context.source(); - - let mut groups = Vec::new(); - if let Some(value) = dictionary.get(b"OCGs") { - for item in value.try_array(objects)? { - let Some(id) = group_id(item, objects)? else { - continue; - }; - // An unreadable group dictionary still identifies a group; only its - // name is lost, so the group is retained without one. - let name = item - .try_dictionary(objects) - .ok() - .and_then(|group| group.optional_bytes_vec(b"Name", objects).ok()) - .flatten(); - groups.push(OptionalContentGroup { id, name }); - } - } - - let default_configuration = match dictionary.optional_dictionary(b"D", objects)? { - Some(configuration) => configuration_from_dictionary(configuration, objects)?, - None => OptionalContentConfiguration::default(), - }; - - Ok(Self { - groups, - default_configuration, - }) - } -} - -/// Decodes the `/D` default configuration; alternate `/Configs` are not retained. -fn configuration_from_dictionary( - dictionary: &Dictionary, - objects: &dyn ObjectResolver, -) -> DecodeResult { - let base_state = match dictionary.optional_bytes(b"BaseState", objects)? { - None | Some(b"ON") => OptionalContentBaseState::On, - Some(b"OFF") => OptionalContentBaseState::Off, - Some(other) => { - return Err(SourceDecodeError::InvalidEntry { - entry: b"BaseState", - reason: format!("unsupported optional content base state '{other:?}'"), - }); - } - }; - Ok(OptionalContentConfiguration { - base_state, - on: group_list(dictionary, b"ON", objects)?, - off: group_list(dictionary, b"OFF", objects)?, - }) -} - -/// Decodes an optional array of group targets, dropping unidentifiable entries. -fn group_list( - dictionary: &Dictionary, - key: &'static [u8], - objects: &dyn ObjectResolver, -) -> DecodeResult> { - let Some(value) = dictionary.get(key) else { - return Ok(Vec::new()); - }; - let mut ids = Vec::new(); - for item in value.try_array(objects)? { - if let Some(id) = group_id(item, objects)? { - ids.push(id); - } - } - Ok(ids) -} - -/// Identifies one group, preferring its reference identity over its `/Name`. -/// -/// Returns `None` for a null entry and for a direct dictionary with neither an -/// object number nor a name, which names no group this state can track. -fn group_id( - value: &ObjectVariant, - objects: &dyn ObjectResolver, -) -> DecodeResult> { - if let ObjectVariant::Reference(id) = value { - return Ok(Some(OptionalContentGroupId::Object { - number: u64::try_from(id.number).map_err(|_| SourceDecodeError::ResourceLimit)?, - })); - } - if value.is_null(objects)? { - return Ok(None); - } - let group = value.try_dictionary(objects)?; - if let Some(number) = group.object_number { - return Ok(Some(OptionalContentGroupId::Object { - number: u64::try_from(number).map_err(|_| SourceDecodeError::ResourceLimit)?, - })); - } - Ok(group - .optional_bytes_vec(b"Name", objects)? - .map(|name| OptionalContentGroupId::Name { name })) -} - -/// Decodes an annotation's `/OC` entry, absent when the annotation declares none. -/// -/// A malformed entry fails the annotation, matching the strictness every other -/// entry in [`crate::source_decode`] applies to a value that is present. -pub(crate) fn optional_content( - dictionary: &Dictionary, - objects: &dyn ObjectResolver, -) -> DecodeResult> { - const ENTRY: &[u8] = b"OC"; - - let Some(value) = dictionary.get(ENTRY) else { - return Ok(None); - }; - if value.is_null(objects)? { - return Ok(None); - } - - let content = dictionary.required_dictionary(ENTRY, objects)?; - match content.optional_bytes(b"Type", objects)? { - Some(b"OCMD") => Ok(Some(AnnotationOptionalContent::Membership(Box::new( - membership(content, objects)?, - )))), - None | Some(b"OCG") => match group_id(value, objects)? { - Some(id) => Ok(Some(AnnotationOptionalContent::Group(id))), - None => Err(SourceDecodeError::MissingEntry { entry: b"Name" }), - }, - Some(other) => Err(SourceDecodeError::InvalidEntry { - entry: ENTRY, - reason: format!("optional content has type '{other:?}'"), - }), - } -} - -/// Decodes an `/OCMD` membership dictionary and its visibility policy. -fn membership( - dictionary: &Dictionary, - objects: &dyn ObjectResolver, -) -> DecodeResult { - // `/OCGs` is a single group or an array of them; both forms are retained as a list. - let groups = match dictionary.get(b"OCGs") { - Some(value) if !value.is_null(objects)? => { - if matches!(resolved(value, objects)?, ObjectVariant::Array(_)) { - group_list(dictionary, b"OCGs", objects)? - } else { - group_id(value, objects)?.into_iter().collect() - } - } - _ => Vec::new(), - }; - - let policy = dictionary - .optional_bytes(b"P", objects)? - .map(OptionalContentPolicy::try_from) - .transpose()? - .unwrap_or_default(); - - Ok(OptionalContentMembership { - groups, - policy, - has_visibility_expression: dictionary.get(b"VE").is_some(), - }) -} - -/// Resolves one level of indirection so an entry's shape can be inspected. -fn resolved<'a>( - value: &'a ObjectVariant, - objects: &'a dyn ObjectResolver, -) -> Result<&'a ObjectVariant, SourceDecodeError> { - match value { - ObjectVariant::Reference(_) => Ok(objects.resolve_object(value)?), - other => Ok(other), - } -} diff --git a/crates/pdf-canvas/Cargo.toml b/crates/pdf-canvas/Cargo.toml index e48ee735..81d2eb2b 100644 --- a/crates/pdf-canvas/Cargo.toml +++ b/crates/pdf-canvas/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" workspace = true [dependencies] +pdf-optional-content = { path = "../pdf-optional-content" } num-traits = "0.2.19" pdf-content-stream-operators = { path = "../pdf-content-stream-operators" } pdf-content-stream = { path = "../pdf-content-stream" } diff --git a/crates/pdf-canvas/src/canvas_marked_content_ops.rs b/crates/pdf-canvas/src/canvas_marked_content_ops.rs index 5c4f2878..f8ce7321 100644 --- a/crates/pdf-canvas/src/canvas_marked_content_ops.rs +++ b/crates/pdf-canvas/src/canvas_marked_content_ops.rs @@ -16,15 +16,34 @@ impl MarkedContentOps for PdfCanvas<'_, B> { Ok(()) } - fn begin_marked_content(&mut self, _tag: &[u8]) -> Result<(), Self::ErrorType> { + fn begin_marked_content(&mut self, tag: &[u8]) -> Result<(), Self::ErrorType> { + if let Some(filter) = &mut self.optional_content { + filter.begin(tag, || None); + } Ok(()) } - fn begin_marked_content_with_properties(&mut self, _tag: &[u8]) -> Result<(), Self::ErrorType> { + fn begin_marked_content_with_properties( + &mut self, + tag: &[u8], + properties: Option<&[u8]>, + ) -> Result<(), Self::ErrorType> { + let Some(filter) = &mut self.optional_content else { + return Ok(()); + }; + // Optional content the resources do not declare stays visible. + let resources = self + .canvas_stack + .last() + .and_then(|state| state.resources.as_deref()); + filter.begin(tag, || resources?.optional_content(properties?)); Ok(()) } fn end_marked_content(&mut self) -> Result<(), Self::ErrorType> { + if let Some(filter) = &mut self.optional_content { + filter.end(); + } Ok(()) } } diff --git a/crates/pdf-canvas/src/canvas_text_ops.rs b/crates/pdf-canvas/src/canvas_text_ops.rs index 6dcf53b6..bb029c77 100644 --- a/crates/pdf-canvas/src/canvas_text_ops.rs +++ b/crates/pdf-canvas/src/canvas_text_ops.rs @@ -301,7 +301,8 @@ impl PdfCanvas<'_, B> { self.paint_outline(&outline, &glyph_transform)?; } } - if let Some(recorded) = &mut self.text_glyphs { + let hidden = self.content_hidden(); + if let Some(recorded) = self.text_glyphs.as_mut().filter(|_| !hidden) { let bounds = base_transform.map_rect(&glyph.bounds).normalized(); if bounds.is_valid() { recorded.push(crate::text::TextGlyph { diff --git a/crates/pdf-canvas/src/lib.rs b/crates/pdf-canvas/src/lib.rs index 6ba5aa47..f16b764a 100644 --- a/crates/pdf-canvas/src/lib.rs +++ b/crates/pdf-canvas/src/lib.rs @@ -27,6 +27,8 @@ mod content_stream_render_state; pub mod error; /// Validated mask descriptions and portable coverage preparation. pub mod mask_layer; +/// Hiding of marked-content sequences whose optional content is off. +mod optional_content_filter; pub use pdf_graphics::viewport::{CanvasViewport, PageViewport, ViewportError}; diff --git a/crates/pdf-canvas/src/optional_content_filter.rs b/crates/pdf-canvas/src/optional_content_filter.rs new file mode 100644 index 00000000..c9f39ee7 --- /dev/null +++ b/crates/pdf-canvas/src/optional_content_filter.rs @@ -0,0 +1,83 @@ +//! Hiding of marked-content sequences whose optional content is off. +use pdf_optional_content::{OptionalContentMembership, OptionalContentState}; + +/// Hides content in marked-content sequences whose optional content is off. +/// +/// 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. + state: &'a OptionalContentState, + /// Number of open marked-content sequences. + depth: usize, + /// Depth of the outermost open sequence that hides its content. + hidden_from: Option, +} + +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 { + state, + depth: 0, + hidden_from: None, + }) + } + + /// Returns a filter over the same state with no open sequences, for a nested canvas. + pub(crate) fn restarted(&self) -> Self { + Self { + state: self.state, + depth: 0, + hidden_from: None, + } + } + + /// Opens a marked-content sequence tagged `tag`. + /// + /// `content` names the optional content an `OC` sequence belongs to; it is only + /// consulted while nothing is hidden yet. + pub(crate) fn begin<'c>( + &mut self, + tag: &[u8], + content: impl FnOnce() -> Option<&'c OptionalContentMembership>, + ) { + self.depth = self.depth.saturating_add(1); + if self.hidden_from.is_none() + && tag == b"OC" + && content().is_some_and(|content| !self.state.is_visible(content)) + { + self.hidden_from = Some(self.depth); + } + } + + /// Closes the innermost open sequence; an unbalanced close is ignored. + pub(crate) fn end(&mut self) { + if self.hidden_from == Some(self.depth) { + self.hidden_from = None; + } + self.depth = self.depth.saturating_sub(1); + } + + /// Returns whether an open sequence hides the content being drawn. + pub(crate) fn is_hidden(&self) -> bool { + self.hidden_from.is_some() + } + + /// Returns the number of open sequences. + pub(crate) fn depth(&self) -> usize { + self.depth + } + + /// Closes the sequences opened beyond `depth`, such as those a content stream left open. + pub(crate) fn close_to(&mut self, depth: usize) { + if self + .hidden_from + .is_some_and(|hidden_from| hidden_from > depth) + { + self.hidden_from = None; + } + self.depth = self.depth.min(depth); + } +} diff --git a/crates/pdf-canvas/src/pdf_canvas.rs b/crates/pdf-canvas/src/pdf_canvas.rs index f9fb8ef3..89ee22a1 100644 --- a/crates/pdf-canvas/src/pdf_canvas.rs +++ b/crates/pdf-canvas/src/pdf_canvas.rs @@ -23,6 +23,9 @@ use pdf_graphics::{ BlendMode, PaintMode, PathFillType, color::Color, pdf_path::PdfPath, rect::Rect, size::Size, transform::Transform, }; +use pdf_optional_content::OptionalContentState; + +use crate::optional_content_filter::OptionalContentFilter; use pdf_resources::{ pattern::{PaintType, Pattern}, resources::Resources, @@ -58,6 +61,8 @@ pub struct PdfCanvas<'a, B: CanvasBackend> { content_stream_render_state: ContentStreamRenderState, /// Optional owned buffer for extracted text glyph positions. pub(crate) text_glyphs: Option>, + /// Hides content whose optional content is off; `None` when nothing can be hidden. + pub(crate) optional_content: Option>, } impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { @@ -115,6 +120,7 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { restore_floor: 1, content_stream_render_state: ContentStreamRenderState::default(), text_glyphs: None, + optional_content: None, }) } @@ -125,6 +131,22 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { self } + /// 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 + } + + /// Returns whether an open marked-content sequence hides the content being drawn. + pub(crate) fn content_hidden(&self) -> bool { + self.optional_content + .as_ref() + .is_some_and(OptionalContentFilter::is_hidden) + } + /// Takes the glyph metadata collected by [`Self::with_text_recording`]. /// /// Returns an empty vector when recording was not enabled. @@ -225,6 +247,10 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { // through a pattern or soft mask is still detected inside it. content_stream_render_state: std::mem::take(&mut self.content_stream_render_state), text_glyphs: None, + optional_content: self + .optional_content + .as_ref() + .map(OptionalContentFilter::restarted), }; // Render the form's content stream into the mask canvas. @@ -515,6 +541,10 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { &mut self, paint: impl FnOnce(&mut B) -> Result<(), PdfCanvasError>, ) -> Result<(), PdfCanvasError> { + // Hidden optional content still updates graphics state, but paints nothing. + if self.content_hidden() { + return Ok(()); + } let Some(mask) = self.current_state()?.soft_mask.clone() else { return paint(self.canvas); }; @@ -709,6 +739,10 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { mut filter: Option<&mut (dyn FnMut(&PdfOperatorVariant) -> bool + '_)>, ) -> Result<(), PdfCanvasError> { let depth = self.canvas_stack.len(); + let marked_content_depth = self + .optional_content + .as_ref() + .map(OptionalContentFilter::depth); self.save()?; let outer_floor = std::mem::replace(&mut self.restore_floor, self.canvas_stack.len()); @@ -744,6 +778,10 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { })(); self.restore_floor = outer_floor; + // Marked-content sequences cannot span streams, so close any left open. + if let (Some(filter), Some(depth)) = (&mut self.optional_content, marked_content_depth) { + filter.close_to(depth); + } let restore_result = self.restore_to(depth); result.and(restore_result) } diff --git a/crates/pdf-content-stream-operators/src/marked_content_operators.rs b/crates/pdf-content-stream-operators/src/marked_content_operators.rs index 9422f1bc..3cb3ce39 100644 --- a/crates/pdf-content-stream-operators/src/marked_content_operators.rs +++ b/crates/pdf-content-stream-operators/src/marked_content_operators.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use pdf_object_reader::object_variant::ObjectVariant; + use crate::{ error::PdfOperatorError, operands::Operands, @@ -44,11 +46,14 @@ impl PdfOperator for BeginMarkedContent { pub struct BeginMarkedContentWithProps { /// The tag indicating the role or nature of the marked-content sequence. tag: Arc<[u8]>, + /// The name of a property list in the `/Properties` resource subdictionary, or + /// `None` when the property list is an inline dictionary. + properties: Option>, } impl BeginMarkedContentWithProps { - pub fn new(tag: Arc<[u8]>) -> Self { - Self { tag } + pub fn new(tag: Arc<[u8]>, properties: Option>) -> Self { + Self { tag, properties } } } @@ -59,13 +64,17 @@ impl PdfOperator for BeginMarkedContentWithProps { fn read(operands: &mut Operands) -> Result { let tag = operands.get_string_bytes()?; + let properties = match operands.peek_next() { + Some(ObjectVariant::String(_)) => Some(operands.get_string_bytes()?), + _ => None, + }; Ok(PdfOperatorVariant::BeginMarkedContentWithProps(Self::new( - tag, + tag, properties, ))) } fn call(&self, backend: &mut T) -> Result<(), BackendError> { - backend.begin_marked_content_with_properties(&self.tag) + backend.begin_marked_content_with_properties(&self.tag, self.properties.as_deref()) } } diff --git a/crates/pdf-content-stream-operators/src/pdf_operator_backend.rs b/crates/pdf-content-stream-operators/src/pdf_operator_backend.rs index 9dda46eb..7447f53a 100644 --- a/crates/pdf-content-stream-operators/src/pdf_operator_backend.rs +++ b/crates/pdf-content-stream-operators/src/pdf_operator_backend.rs @@ -807,11 +807,17 @@ pub trait MarkedContentOps { /// # Parameters /// /// - `tag`: The tag for the marked-content sequence. + /// - `properties`: The name of a property list in the `/Properties` resource + /// subdictionary, or `None` when the property list is an inline dictionary. /// /// # Returns /// /// A `Result` indicating success or an `ErrorType` on failure. - fn begin_marked_content_with_properties(&mut self, tag: &[u8]) -> Result<(), Self::ErrorType>; + fn begin_marked_content_with_properties( + &mut self, + tag: &[u8], + properties: Option<&[u8]>, + ) -> Result<(), Self::ErrorType>; /// Ends a marked-content sequence. /// diff --git a/crates/pdf-content-stream-operators/src/recording_pdf_operator_backend.rs b/crates/pdf-content-stream-operators/src/recording_pdf_operator_backend.rs index f492c353..ddc34c8d 100644 --- a/crates/pdf-content-stream-operators/src/recording_pdf_operator_backend.rs +++ b/crates/pdf-content-stream-operators/src/recording_pdf_operator_backend.rs @@ -207,6 +207,7 @@ pub enum RecordedOperation { }, BeginMarkedContentWithProperties { tag: Vec, + properties: Option>, }, EndMarkedContent, } @@ -729,9 +730,16 @@ impl MarkedContentOps for RecordingBackend { Ok(()) } - fn begin_marked_content_with_properties(&mut self, tag: &[u8]) -> Result<(), Self::ErrorType> { + fn begin_marked_content_with_properties( + &mut self, + tag: &[u8], + properties: Option<&[u8]>, + ) -> Result<(), Self::ErrorType> { self.operations - .push(RecordedOperation::BeginMarkedContentWithProperties { tag: tag.to_vec() }); + .push(RecordedOperation::BeginMarkedContentWithProperties { + tag: tag.to_vec(), + properties: properties.map(<[u8]>::to_vec), + }); Ok(()) } diff --git a/crates/pdf-document/Cargo.toml b/crates/pdf-document/Cargo.toml index 94c92a66..3fd83650 100644 --- a/crates/pdf-document/Cargo.toml +++ b/crates/pdf-document/Cargo.toml @@ -11,6 +11,7 @@ serde = ["dep:serde", "pdf-object-reader/serde"] pdf-utils = { path = "../pdf-utils" } pdf-content-stream = { path = "../pdf-content-stream" } pdf-annotation-core = { path = "../pdf-annotation-core" } +pdf-optional-content = { path = "../pdf-optional-content" } pdf-object-collection = { path = "../pdf-object-collection" } pdf-object-reader = { path = "../pdf-object-reader" } pdf-parser = { path = "../pdf-parser" } diff --git a/crates/pdf-document/src/catalog.rs b/crates/pdf-document/src/catalog.rs index 3216f8a5..ef4fa3a8 100644 --- a/crates/pdf-document/src/catalog.rs +++ b/crates/pdf-document/src/catalog.rs @@ -1,11 +1,11 @@ //! Decodes the document catalog referenced by the trailer's `/Root` entry. -use pdf_annotation_core::OptionalContentProperties; use pdf_object_collection::object_collection::ObjectCollection; use pdf_object_reader::{ FromPdfObject, ObjectAccess, ObjectContext, ObjectReader, ReadResult, object_id::ObjectId, object_variant::ObjectVariant, }; +use pdf_optional_content::OptionalContentProperties; use crate::diagnostic::{PdfReadDiagnostic, PdfReadDiagnosticKind}; use crate::document::PdfDocument; diff --git a/crates/pdf-document/src/document.rs b/crates/pdf-document/src/document.rs index bc0e96cc..f1e0dc46 100644 --- a/crates/pdf-document/src/document.rs +++ b/crates/pdf-document/src/document.rs @@ -1,6 +1,6 @@ -use pdf_annotation_core::OptionalContentProperties; use pdf_object_collection::object_collection::ObjectCollection; use pdf_object_reader::ObjectReader; +use pdf_optional_content::OptionalContentProperties; use crate::page::PdfPage; diff --git a/crates/pdf-object-reader/src/context.rs b/crates/pdf-object-reader/src/context.rs index 479a141e..07f2e3c2 100644 --- a/crates/pdf-object-reader/src/context.rs +++ b/crates/pdf-object-reader/src/context.rs @@ -206,6 +206,47 @@ impl<'read, A: ObjectAccess + ?Sized> DictionaryContext<'read, A> { } Ok(entries) } + + /// Decodes each entry of an optional sub-dictionary as `T`, keyed by entry name, skipping + /// entries that do not decode as `T`, for namespaces that mix value kinds. + /// + /// A missing or null sub-dictionary yields an empty map, as with [`Self::optional_entries`]. + pub fn lenient_entries( + &mut self, + key: &[u8], + ) -> ReadResult, T>> { + let Some(dictionary) = self.optional::(key)? else { + return Ok(HashMap::new()); + }; + Ok(dictionary + .dictionary + .iter() + .filter_map(|(name, value)| Some((name.clone(), self.access.read(value).ok()?))) + .collect()) + } + + /// Reads an entry holding one value or an array of values, as a list. + /// + /// A missing or null entry yields an empty list, and null array members, such as + /// references to missing objects, are skipped. + pub fn optional_list(&mut self, key: &[u8]) -> ReadResult> { + let Some(object) = self.dictionary.get(key) else { + return Ok(Vec::new()); + }; + let resolved = self.access.resolve(object); + let result = resolved.and_then(|resolved| match resolved.value() { + ObjectVariant::Array(array) => array + .iter() + .filter_map(|member| self.access.read::>(member).transpose()) + .collect(), + ObjectVariant::Null => Ok(Vec::new()), + _ => Ok(vec![self.access.read(object)?]), + }); + result.map_err(|source| ObjectReadError::At { + location: ReadLocation::DictionaryKey(PdfName::from_bytes(key)), + source: Box::new(source), + }) + } } /// Provides bounds-checked typed array reads in source order. diff --git a/crates/pdf-optional-content/Cargo.toml b/crates/pdf-optional-content/Cargo.toml new file mode 100644 index 00000000..31f8db04 --- /dev/null +++ b/crates/pdf-optional-content/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pdf-optional-content" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[features] +typescript = ["dep:ts-rs"] + +[dependencies] +ts-rs = { version = "12", optional = true } +serde = { version = "1", features = ["derive"] } +thiserror = "2.0.12" +pdf-object-reader = { path = "../pdf-object-reader" } diff --git a/crates/pdf-optional-content/src/decode.rs b/crates/pdf-optional-content/src/decode.rs new file mode 100644 index 00000000..231f47a1 --- /dev/null +++ b/crates/pdf-optional-content/src/decode.rs @@ -0,0 +1,124 @@ +//! Decodes optional content declarations into owned data. +//! +//! A group is identified by the object number of its dictionary, so group references are +//! resolved only to read that number and the group's `/Name`. +use crate::error::OptionalContentError; +use crate::optional_content::{ + OptionalContentBaseState, OptionalContentConfiguration, OptionalContentGroup, + OptionalContentGroupId, OptionalContentMembership, OptionalContentPolicy, + OptionalContentProperties, +}; +use pdf_object_reader::{FromPdfObject, ObjectAccess, ObjectContext, ReadResult}; +use std::sync::Arc; + +/// Decodes the catalog's `/OCProperties`; alternate `/Configs` are not retained. +impl FromPdfObject for OptionalContentProperties { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + let mut context = context.dictionary()?; + Ok(Self { + groups: context.optional_list(b"OCGs")?, + default_configuration: context.optional(b"D")?.unwrap_or_default(), + }) + } +} + +/// Decodes the `/D` default configuration. +impl FromPdfObject for OptionalContentConfiguration { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + let mut context = context.dictionary()?; + Ok(Self { + base_state: context.optional(b"BaseState")?.unwrap_or_default(), + on: context.optional_list(b"ON")?, + off: context.optional_list(b"OFF")?, + }) + } +} + +/// Reads a `/BaseState` name. An absent entry is the caller's `On` default. +impl FromPdfObject for OptionalContentBaseState { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + match context.object().value().try_bytes(context.source())? { + b"ON" => Ok(Self::On), + b"OFF" => Ok(Self::Off), + other => Err(OptionalContentError::InvalidEntry { + entry: b"BaseState", + reason: format!("unsupported optional content base state '{other:?}'"), + } + .into()), + } + } +} + +/// Reads a `/P` policy name. An absent entry is the caller's `AnyOn` default. +impl FromPdfObject for OptionalContentPolicy { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + match context.object().value().try_bytes(context.source())? { + b"AnyOn" => Ok(Self::AnyOn), + b"AllOn" => Ok(Self::AllOn), + b"AnyOff" => Ok(Self::AnyOff), + b"AllOff" => Ok(Self::AllOff), + other => Err(OptionalContentError::InvalidEntry { + entry: b"P", + reason: format!("unsupported optional content policy '{other:?}'"), + } + .into()), + } + } +} + +/// Decodes one group dictionary, identified by its object number or, for a direct +/// dictionary, by its `/Name`. +impl FromPdfObject for OptionalContentGroup { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + let mut context = context.dictionary()?; + // A malformed name never discards an otherwise identifiable group. + let name = context.lenient::>(b"Name"); + Ok(Self { + id: OptionalContentGroupId::new(context.dictionary().object_number, name.as_deref())?, + name: name.map(|name| name.to_vec()), + }) + } +} + +/// Identifies one group dictionary by its object number or, for a direct dictionary, +/// by its `/Name`. +impl FromPdfObject for OptionalContentGroupId { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + let mut context = context.dictionary()?; + let name = context.lenient::>(b"Name"); + Ok(Self::new( + context.dictionary().object_number, + name.as_deref(), + )?) + } +} + +/// Decodes an `/OCMD` or `/OCG` dictionary, such as an annotation's `/OC` entry or a +/// content stream's `/Properties` resource entry naming the optional content a +/// marked-content sequence belongs to. +impl FromPdfObject for OptionalContentMembership { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + let mut context = context.dictionary()?; + match context.lenient::>(b"Type").as_deref() { + Some(b"OCMD") => Ok(Self { + // `/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.dictionary().get(b"VE").is_some(), + }), + // 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. + _ => { + let name = context.lenient::>(b"Name"); + Ok(Self { + groups: vec![OptionalContentGroupId::new( + context.dictionary().object_number, + name.as_deref(), + )?], + policy: OptionalContentPolicy::AnyOn, + has_visibility_expression: false, + }) + } + } + } +} diff --git a/crates/pdf-optional-content/src/error.rs b/crates/pdf-optional-content/src/error.rs new file mode 100644 index 00000000..d33d0e1f --- /dev/null +++ b/crates/pdf-optional-content/src/error.rs @@ -0,0 +1,35 @@ +//! Errors decoding optional content declarations. + +/// A failure decoding an optional content declaration. +#[derive(Debug, thiserror::Error)] +pub enum OptionalContentError { + /// An error returned while reading or resolving a PDF object. + #[error("{0}")] + Object(#[from] pdf_object_reader::object_error::ObjectError), + /// An entry is present but has the wrong type or value shape. + #[error("invalid optional content entry '/{entry:?}': {reason}")] + InvalidEntry { + /// The dictionary key of the offending entry. + entry: &'static [u8], + /// Why the entry was rejected. + reason: String, + }, + /// A required entry is missing. + #[error("missing required optional content entry '/{entry:?}'")] + MissingEntry { + /// The dictionary key of the missing entry. + entry: &'static [u8], + }, + /// An object number cannot be represented as a group identity. + #[error("optional content group object number is out of range")] + ResourceLimit, +} + +impl From for pdf_object_reader::ObjectReadError { + fn from(source: OptionalContentError) -> Self { + Self::Decode { + target: "optional content", + source: Box::new(source), + } + } +} diff --git a/crates/pdf-optional-content/src/lib.rs b/crates/pdf-optional-content/src/lib.rs new file mode 100644 index 00000000..94a33bec --- /dev/null +++ b/crates/pdf-optional-content/src/lib.rs @@ -0,0 +1,20 @@ +//! Optional content groups: the catalog's `/OCProperties`, the `/OCG` and `/OCMD` +//! references that make page content and annotations conditional, and the runtime +//! visibility state that decides whether they are shown. + +mod decode; +/// Errors decoding optional content declarations. +pub mod error; +/// Optional content group state operations of `SetOCGState` actions. +pub mod ocg_state; +/// Optional content group visibility applied to page content and annotations. +pub mod optional_content; +mod wire; + +pub use error::OptionalContentError; +pub use ocg_state::{OcgStateEntry, OcgTarget}; +pub use optional_content::{ + OptionalContentBaseState, OptionalContentConfiguration, OptionalContentGroup, + OptionalContentGroupId, OptionalContentMembership, OptionalContentPolicy, + OptionalContentProperties, OptionalContentState, +}; diff --git a/crates/pdf-annotation-core/src/ocg_state.rs b/crates/pdf-optional-content/src/ocg_state.rs similarity index 91% rename from crates/pdf-annotation-core/src/ocg_state.rs rename to crates/pdf-optional-content/src/ocg_state.rs index 1b68ad3f..9f788228 100644 --- a/crates/pdf-annotation-core/src/ocg_state.rs +++ b/crates/pdf-optional-content/src/ocg_state.rs @@ -1,5 +1,5 @@ //! Optional content group state retained from `SetOCGState` actions. -use crate::error::SourceDecodeError; +use crate::error::OptionalContentError; /// Owned optional content group target of a `SetOCGState` `/State` element. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -9,7 +9,7 @@ pub enum OcgTarget { /// A group reference into the containing source PDF. The referenced group /// resource is not resolved, preserving the reference identity. Reference { - /// PDF object number, distinct from Core annotation and field identities. + /// PDF object number of the group's dictionary. #[serde(with = "crate::wire")] #[cfg_attr(feature = "typescript", ts(type = "string"))] number: u64, @@ -46,14 +46,14 @@ pub enum OcgStateEntry { /// Reads a `/State` operation name; group targets are decoded from the objects /// that follow their operation and never reach this conversion. impl TryFrom<&[u8]> for OcgStateEntry { - type Error = SourceDecodeError; + type Error = OptionalContentError; fn try_from(name: &[u8]) -> Result { match name { b"ON" => Ok(Self::On), b"OFF" => Ok(Self::Off), b"Toggle" => Ok(Self::Toggle), - other => Err(SourceDecodeError::InvalidEntry { + other => Err(OptionalContentError::InvalidEntry { entry: b"State", reason: format!("unsupported optional content operation '{other:?}'"), }), diff --git a/crates/pdf-annotation-core/src/optional_content.rs b/crates/pdf-optional-content/src/optional_content.rs similarity index 77% rename from crates/pdf-annotation-core/src/optional_content.rs rename to crates/pdf-optional-content/src/optional_content.rs index f9be8c97..168ccdf3 100644 --- a/crates/pdf-annotation-core/src/optional_content.rs +++ b/crates/pdf-optional-content/src/optional_content.rs @@ -1,10 +1,11 @@ -//! Optional content group visibility applied to annotation presentation. +//! Optional content group visibility applied to page content and annotations. //! //! The catalog's `/OCProperties` seeds which groups start visible; an annotation's -//! `/OC` entry names the group or membership dictionary its display depends on. +//! `/OC` entry, or a `/Properties` resource named by a marked-content sequence, +//! names the group or membership dictionary its display depends on. //! Visibility is host presentation state: it never enters the sidecar and never //! advances the document revision. -use crate::error::SourceDecodeError; +use crate::error::OptionalContentError; use crate::ocg_state::{OcgStateEntry, OcgTarget}; use std::collections::{BTreeMap, BTreeSet}; @@ -26,7 +27,7 @@ use std::collections::{BTreeMap, BTreeSet}; pub enum OptionalContentGroupId { /// A group identified by the indirect object number of its `/OCG` dictionary. Object { - /// PDF object number, distinct from Core annotation and field identities. + /// PDF object number of the group's dictionary. #[serde(with = "crate::wire")] #[cfg_attr(feature = "typescript", ts(type = "string"))] number: u64, @@ -38,6 +39,25 @@ pub enum OptionalContentGroupId { }, } +impl OptionalContentGroupId { + /// Identifies a group by the object number of its dictionary or, for a direct + /// dictionary, by its `/Name`. + pub(crate) fn new( + object_number: Option, + name: Option<&[u8]>, + ) -> Result { + match (object_number, name) { + (Some(number), _) => Ok(Self::Object { + number: u64::try_from(number).map_err(|_| OptionalContentError::ResourceLimit)?, + }), + (None, Some(name)) => Ok(Self::Name { + name: name.to_vec(), + }), + (None, None) => Err(OptionalContentError::MissingEntry { entry: b"Name" }), + } + } +} + /// The `/P` visibility policy of an `/OCMD` membership dictionary. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -54,32 +74,16 @@ pub enum OptionalContentPolicy { AllOff, } -/// Reads a `/P` policy name. An absent entry is the `AnyOn` default and never -/// reaches this conversion. -impl TryFrom<&[u8]> for OptionalContentPolicy { - type Error = SourceDecodeError; - - fn try_from(name: &[u8]) -> Result { - match name { - b"AnyOn" => Ok(Self::AnyOn), - b"AllOn" => Ok(Self::AllOn), - b"AnyOff" => Ok(Self::AnyOff), - b"AllOff" => Ok(Self::AllOff), - other => Err(SourceDecodeError::InvalidEntry { - entry: b"P", - reason: format!("unsupported optional content policy '{other:?}'"), - }), - } - } -} - -/// Owned `/OCMD` membership dictionary retained from the source PDF. +/// The optional content an annotation or marked-content sequence depends on. +/// +/// Declared either as an `/OCMD` membership dictionary or as a single `/OCG`, which +/// means the same as a membership of that one group under the default `AnyOn` policy. #[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 struct OptionalContentMembership { - /// The `/OCGs` member groups. A single group reference is retained as a - /// one-element list. Null members are dropped rather than retained. + /// The member groups. A single group is retained as a one-element list. Null + /// members are dropped rather than retained. pub groups: Vec, /// The `/P` policy; `AnyOn` when the entry is absent. pub policy: OptionalContentPolicy, @@ -89,26 +93,12 @@ pub struct OptionalContentMembership { pub has_visibility_expression: bool, } -/// Owned `/OC` entry of an annotation: a single group, or a membership dictionary. -#[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 AnnotationOptionalContent { - /// A direct `/OCG` group reference. - Group(OptionalContentGroupId), - /// An `/OCMD` membership dictionary. - Membership(Box), -} - -impl AnnotationOptionalContent { +impl OptionalContentMembership { /// Whether any group this content's visibility depends on appears in `changed`. /// /// Callers use this to refresh only the pages a state change can affect. pub fn depends_on(&self, changed: &BTreeSet) -> bool { - match self { - Self::Group(id) => changed.contains(id), - Self::Membership(membership) => membership.groups.iter().any(|id| changed.contains(id)), - } + self.groups.iter().any(|id| changed.contains(id)) } } @@ -155,8 +145,7 @@ pub struct OptionalContentProperties { /// Runtime visibility of a document's optional content groups. /// -/// This is disposable presentation state owned by the annotation overlay. It is -/// seeded from [`OptionalContentProperties`] and mutated by `SetOCGState` actions; +/// This is disposable presentation state. It is seeded from [`OptionalContentProperties`] and mutated by `SetOCGState` actions; /// it is never serialized into the sidecar and never advances a document revision. #[derive(Clone, Debug, Default)] pub struct OptionalContentState { @@ -199,26 +188,21 @@ impl OptionalContentState { state } - /// Whether an annotation carrying `content` is currently displayed. + /// 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. - pub fn is_visible(&self, content: &AnnotationOptionalContent) -> bool { - match content { - AnnotationOptionalContent::Group(id) => self.group_visible(id), - AnnotationOptionalContent::Membership(membership) => { - if membership.has_visibility_expression || membership.groups.is_empty() { - return true; - } - let mut groups = membership.groups.iter().map(|id| self.group_visible(id)); - match membership.policy { - OptionalContentPolicy::AnyOn => groups.any(|on| on), - OptionalContentPolicy::AllOn => groups.all(|on| on), - OptionalContentPolicy::AnyOff => groups.any(|on| !on), - OptionalContentPolicy::AllOff => groups.all(|on| !on), - } - } + pub fn is_visible(&self, content: &OptionalContentMembership) -> bool { + if content.has_visibility_expression || content.groups.is_empty() { + return true; + } + let mut groups = content.groups.iter().map(|id| self.group_visible(id)); + match content.policy { + OptionalContentPolicy::AnyOn => groups.any(|on| on), + OptionalContentPolicy::AllOn => groups.all(|on| on), + OptionalContentPolicy::AnyOff => groups.any(|on| !on), + OptionalContentPolicy::AllOff => groups.all(|on| !on), } } @@ -258,6 +242,11 @@ 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 { diff --git a/crates/pdf-optional-content/src/wire.rs b/crates/pdf-optional-content/src/wire.rs new file mode 100644 index 00000000..b7723014 --- /dev/null +++ b/crates/pdf-optional-content/src/wire.rs @@ -0,0 +1,26 @@ +//! Lossless decimal encoding for 64-bit object numbers in sidecars and browser messages. +use serde::{Deserialize, Deserializer, Serializer, de::Error}; + +pub(crate) fn serialize( + value: &T, + serializer: S, +) -> Result { + serializer.collect_str(value) +} + +pub(crate) fn deserialize<'de, T: std::str::FromStr, D: Deserializer<'de>>( + deserializer: D, +) -> Result { + let text = String::deserialize(deserializer)?; + // Require canonical decimal spelling, avoiding lossy JS numeric coercion. + let digits = text.strip_prefix('-').unwrap_or(&text); + if digits.is_empty() + || !digits.bytes().all(|byte| byte.is_ascii_digit()) + || (digits.starts_with('0') && digits.len() > 1) + || text == "-0" + { + return Err(D::Error::custom("expected canonical decimal integer")); + } + text.parse() + .map_err(|_| D::Error::custom("decimal integer is out of range")) +} diff --git a/crates/pdf-renderer/Cargo.toml b/crates/pdf-renderer/Cargo.toml index 27bb67a0..b281dff7 100644 --- a/crates/pdf-renderer/Cargo.toml +++ b/crates/pdf-renderer/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" error-backtrace = ["pdf-utils/error-backtrace"] [dependencies] +pdf-optional-content = { path = "../pdf-optional-content" } pdf-utils = { path = "../pdf-utils" } pdf-content-stream = { path = "../pdf-content-stream" } pdf-document = { path = "../pdf-document" } diff --git a/crates/pdf-renderer/src/lib.rs b/crates/pdf-renderer/src/lib.rs index 48b4ee88..99cd26bd 100644 --- a/crates/pdf-renderer/src/lib.rs +++ b/crates/pdf-renderer/src/lib.rs @@ -3,6 +3,7 @@ use pdf_canvas::{ recording_canvas::RecordingCanvas, }; use pdf_document::document::PdfDocument; +use pdf_optional_content::OptionalContentState; use pdf_text_engine::FontSystem; use std::sync::Arc; use thiserror::Error; @@ -18,15 +19,18 @@ pub use text_selection::{ }; /// Renders page content and captures text using a shared viewport, excluding annotations. +/// Content in optional content groups that are off in `optional_content` is hidden. /// Backend lifecycle and any separate annotation pass remain the caller's responsibility. pub fn render_page_content_with_text_layout( backend: &mut B, page: &pdf_document::page::PdfPage, viewport: &pdf_canvas::PageViewport, fonts: Arc, + optional_content: &OptionalContentState, ) -> Result { - let mut canvas = - PdfCanvas::new_with_viewport(backend, page, viewport, fonts)?.with_text_recording(); + let mut canvas = PdfCanvas::new_with_viewport(backend, page, viewport, fonts)? + .with_text_recording() + .with_optional_content(optional_content); if let Some(content) = &page.contents { canvas.render_content_stream(content, None, None, page.resources.clone(), None)?; } @@ -150,9 +154,11 @@ impl PdfRenderer { page_index: usize, ) -> Result<(), PdfRendererError> { let page = self.page(page_index)?; + let optional_content = self.optional_content(); { let mut canvas = - PdfCanvas::new(canvas_backend, page, None, Arc::clone(&self.font_system))?; + PdfCanvas::new(canvas_backend, page, None, Arc::clone(&self.font_system))? + .with_optional_content(&optional_content); if let Some(cs) = &page.contents { canvas.render_content_stream(cs, None, None, page.resources.clone(), None)?; } @@ -167,8 +173,10 @@ impl PdfRenderer { page_index: usize, ) -> Result { let page = self.page(page_index)?; + let optional_content = self.optional_content(); let mut canvas = PdfCanvas::new(canvas_backend, page, None, Arc::clone(&self.font_system))? - .with_text_recording(); + .with_text_recording() + .with_optional_content(&optional_content); if let Some(cs) = &page.contents { canvas.render_content_stream(cs, None, None, page.resources.clone(), None)?; } @@ -188,10 +196,12 @@ impl PdfRenderer { ) -> Result { let page = self.page(page_index)?; let mut recording = RecordingCanvas::new(width, height); + let optional_content = self.optional_content(); let glyphs = { let mut canvas = PdfCanvas::new(&mut recording, page, None, Arc::clone(&self.font_system))? - .with_text_recording(); + .with_text_recording() + .with_optional_content(&optional_content); if let Some(cs) = &page.contents { canvas.render_content_stream(cs, None, None, page.resources.clone(), None)?; } @@ -203,6 +213,15 @@ impl PdfRenderer { }) } + /// Returns the visibility the document's default optional content configuration sets. + fn optional_content(&self) -> OptionalContentState { + self.document + .optional_content + .as_ref() + .map(OptionalContentState::from_properties) + .unwrap_or_default() + } + fn page(&self, page_index: usize) -> Result<&pdf_document::page::PdfPage, PdfRendererError> { let Some(page) = self.document.pages.get(page_index) else { return Err(PdfRendererError::PageNotFound(page_index)); diff --git a/crates/pdf-resources/Cargo.toml b/crates/pdf-resources/Cargo.toml index 3920ef8d..2f569ae7 100644 --- a/crates/pdf-resources/Cargo.toml +++ b/crates/pdf-resources/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" workspace = true [dependencies] +pdf-optional-content = { path = "../pdf-optional-content" } pdf-object-reader = { path = "../pdf-object-reader" } pdf-object-collection = { path = "../pdf-object-collection" } pdf-content-stream-operators = { path = "../pdf-content-stream-operators" } diff --git a/crates/pdf-resources/src/resources.rs b/crates/pdf-resources/src/resources.rs index 73481990..485c987c 100644 --- a/crates/pdf-resources/src/resources.rs +++ b/crates/pdf-resources/src/resources.rs @@ -6,6 +6,7 @@ use pdf_object_reader::{ DictionaryContext, FromPdfObject, ObjectAccess, ObjectContext, ObjectHandle, ReadResult, object_variant::ObjectVariant, }; +use pdf_optional_content::OptionalContentMembership; use pdf_shading::model::Shading; use std::{collections::HashMap, sync::Arc}; @@ -30,6 +31,8 @@ pub struct Resources { pub shadings: HashMap, Resource>, /// The /ColorSpace resource namespace. pub color_spaces: HashMap, Resource>, + /// The optional content group and membership entries of the /Properties namespace. + pub optional_content: HashMap, OptionalContentMembership>, } impl FromPdfObject for Resources { @@ -68,6 +71,8 @@ impl Resources { color_spaces: context.optional_entries(b"ColorSpace", |context, value| { Ok(Resource::ColorSpace(context.read_shared(value)?.get()?)) })?, + // Other property lists, such as tagged content's, are skipped. + optional_content: context.lenient_entries(b"Properties")?, }) } @@ -121,6 +126,15 @@ impl Resources { self.xobjects.get(name.as_ref())?.get().ok() } + /// Returns the optional content a `/Properties` entry names, if it exists. + /// + /// # Parameters + /// + /// - `name`: The property list name as referenced by a `BDC` operator. + pub fn optional_content>(&self, name: N) -> Option<&OptionalContentMembership> { + self.optional_content.get(name.as_ref()) + } + /// Returns a reference to a pattern resource by name, if it exists. /// /// # Parameters @@ -181,6 +195,10 @@ impl Resources { xobjects: Self::merged_category(&child.xobjects, &parent.xobjects), shadings: Self::merged_category(&child.shadings, &parent.shadings), color_spaces: Self::merged_category(&child.color_spaces, &parent.color_spaces), + optional_content: Self::merged_category( + &child.optional_content, + &parent.optional_content, + ), }) } diff --git a/crates/pdf-web/Cargo.toml b/crates/pdf-web/Cargo.toml index 7b852319..976e1bac 100644 --- a/crates/pdf-web/Cargo.toml +++ b/crates/pdf-web/Cargo.toml @@ -13,6 +13,7 @@ typescript = ["dep:ts-rs", "pdf-annotation-core/typescript"] [dependencies] ts-rs = { version = "12", optional = true } pdf-annotation-core = { path = "../pdf-annotation-core" } +pdf-optional-content = { path = "../pdf-optional-content" } pdf-font = { path = "../pdf-font" } pdf-canvas = { path = "../pdf-canvas" } pdf-graphics = { path = "../pdf-graphics" } diff --git a/crates/pdf-web/js/annotation_contract.ts b/crates/pdf-web/js/annotation_contract.ts index ffa9b231..bccb2a59 100644 --- a/crates/pdf-web/js/annotation_contract.ts +++ b/crates/pdf-web/js/annotation_contract.ts @@ -167,11 +167,6 @@ source: SourceAnnotation | null, */ action: AnnotationAction | null, }; -/** - * Owned `/OC` entry of an annotation: a single group, or a membership dictionary. - */ -export type AnnotationOptionalContent = { "Group": OptionalContentGroupId } | { "Membership": OptionalContentMembership }; - /** * Acknowledgement of a committed in-memory edit, never a storage acknowledgement. */ @@ -1030,7 +1025,7 @@ export type OcgStateEntry = "On" | "Off" | "Toggle" | { "Group": OcgTarget }; */ export type OcgTarget = { "Reference": { /** - * PDF object number, distinct from Core annotation and field identities. + * PDF object number of the group's dictionary. */ number: string, /** @@ -1156,7 +1151,7 @@ export type OptionId = number; */ export type OptionalContentGroupId = { "Object": { /** - * PDF object number, distinct from Core annotation and field identities. + * PDF object number of the group's dictionary. */ number: string, } } | { "Name": { /** @@ -1165,12 +1160,15 @@ number: string, } } | { "Name": { name: Array, } }; /** - * Owned `/OCMD` membership dictionary retained from the source PDF. + * The optional content an annotation or marked-content sequence depends on. + * + * Declared either as an `/OCMD` membership dictionary or as a single `/OCG`, which + * means the same as a membership of that one group under the default `AnyOn` policy. */ export type OptionalContentMembership = { /** - * The `/OCGs` member groups. A single group reference is retained as a - * one-element list. Null members are dropped rather than retained. + * The member groups. A single group is retained as a one-element list. Null + * members are dropped rather than retained. */ groups: Array, /** @@ -1568,7 +1566,7 @@ struct_parent: string | null, * The optional `/OC` optional content membership governing display. * Defaulted on load so sidecars written before this entry still deserialize. */ -optional_content?: AnnotationOptionalContent, +optional_content?: OptionalContentMembership, /** * The parsed subtype-specific payload. */ diff --git a/crates/pdf-web/src/annotation_overlay.rs b/crates/pdf-web/src/annotation_overlay.rs index 286a5da8..ccfa0ca3 100644 --- a/crates/pdf-web/src/annotation_overlay.rs +++ b/crates/pdf-web/src/annotation_overlay.rs @@ -6,6 +6,7 @@ use pdf_annotation_core::{ }; use pdf_document::{diagnostic::PdfReadDiagnostic, document::PdfDocument}; use pdf_graphics::{rect::Rect, transform::Transform}; +use pdf_optional_content::OptionalContentState; /// Prepared page entries placed for the DOM host. #[derive(Clone)] @@ -152,6 +153,11 @@ impl WebAnnotationOverlay { Ok(self.overlay.set_optional_content_state(action)?) } + /// Returns the runtime optional content visibility page content is rendered with. + pub(crate) fn optional_content(&self) -> &OptionalContentState { + self.overlay.optional_content() + } + /// Drops only a page's disposable entries; Core values survive eviction. pub fn remove_page(&mut self, page: u32) { self.overlay.remove_page(page); diff --git a/crates/pdf-web/src/page_renderer.rs b/crates/pdf-web/src/page_renderer.rs index 06e3815a..2c2da79d 100644 --- a/crates/pdf-web/src/page_renderer.rs +++ b/crates/pdf-web/src/page_renderer.rs @@ -70,6 +70,7 @@ impl WebPageRenderer { page, viewport.page(), Arc::clone(&self.fonts), + overlay.optional_content(), )?); backend.finish_page()?; let annotations = overlay.prepare(page_index, viewport)?;