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)?;