Skip to main content

pdfrum_page/state/
marks.rs

1//! Marked content: `BMC`, `BDC`, `EMC` (ISO 32000-1 §14.6).
2//!
3//! The stack carries a **sentinel** at the bottom that `EMC` can never pop,
4//! so a content stream with more `EMC`s than `BDC`s is harmless rather than
5//! corrupting.
6//!
7//! `BDC` is fussier than it looks: a null tag pushes **nothing at all**, and
8//! a named property list that does not resolve through `/Properties` pushes
9//! nothing either — so an unresolvable `BDC` leaves the mark stack untouched
10//! and its matching `EMC` then pops a mark it did not push.
11
12use crate::ops::MarkProperties;
13use pdfrum_object::{Dict, Name};
14use std::sync::Arc;
15
16/// One marked-content entry.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Mark {
19    /// The tag naming the role, e.g. `/Span` or `/OC`.
20    pub tag: Name,
21    /// The property list, when the operator carried one that resolved.
22    pub properties: Option<Arc<Dict>>,
23    /// Whether the properties came from the `/Properties` resource rather
24    /// than being written inline — which is what optional-content visibility
25    /// requires.
26    pub from_resources: bool,
27}
28
29impl Mark {
30    /// The `/MCID` this mark's properties declare.
31    #[must_use]
32    pub fn content_id(&self) -> Option<i64> {
33        self.properties
34            .as_ref()
35            .and_then(|d| d.direct_int(&Name::from("MCID")))
36    }
37}
38
39/// The mark stack a page object is stamped with.
40///
41/// Cheap to clone: `Arc` inside, and page objects snapshot it at creation.
42#[derive(Debug, Clone, PartialEq, Default)]
43pub struct ContentMarks {
44    marks: Vec<Mark>,
45}
46
47impl ContentMarks {
48    /// An empty stack.
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// The marks, outermost first.
55    #[must_use]
56    pub fn marks(&self) -> &[Mark] {
57        &self.marks
58    }
59
60    /// How many marks are open.
61    #[must_use]
62    pub fn len(&self) -> usize {
63        self.marks.len()
64    }
65
66    /// Whether nothing is marked.
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        self.marks.is_empty()
70    }
71
72    /// `BMC`: push a tag with no properties.
73    pub fn push(&mut self, tag: Name) {
74        self.marks.push(Mark {
75            tag,
76            properties: None,
77            from_resources: false,
78        });
79    }
80
81    /// `BDC`: push a tag with a property list.
82    ///
83    /// `resolve` is what turns a named property list into a dictionary; when
84    /// it yields nothing, **no mark is pushed at all**.
85    pub fn push_with_properties(
86        &mut self,
87        tag: Name,
88        properties: &MarkProperties,
89        resolve: impl FnOnce(&Name) -> Option<Dict>,
90    ) -> bool {
91        let (dict, from_resources) = match properties {
92            MarkProperties::Named(name) => match resolve(name) {
93                Some(d) => (d, true),
94                // An unresolvable name pushes nothing.
95                None => return false,
96            },
97            // An inline dictionary is stored as a clone.
98            MarkProperties::Inline(d) => ((**d).clone(), false),
99        };
100        self.marks.push(Mark {
101            tag,
102            properties: Some(Arc::new(dict)),
103            from_resources,
104        });
105        true
106    }
107
108    /// `EMC`: pop one mark, never past the sentinel.
109    ///
110    /// Answers whether anything was popped — a question, not a failed
111    /// mutation — so a caller can record the imbalance.
112    pub fn pop(&mut self) -> bool {
113        self.marks.pop().is_some()
114    }
115
116    /// The `/MCID` of the **first** mark that declares one, or `None`.
117    #[must_use]
118    pub fn content_id(&self) -> Option<i64> {
119        self.marks.iter().find_map(Mark::content_id)
120    }
121
122    /// The optional-content group this content belongs to, if any.
123    ///
124    /// Only a mark tagged exactly `OC` **whose properties came from the
125    /// `/Properties` resource** counts — an inline `BDC /OC << … >>` is
126    /// ignored entirely and its content stays visible.
127    #[must_use]
128    pub fn optional_content(&self) -> Option<&Dict> {
129        self.optional_content_all().into_iter().next()
130    }
131
132    /// **Every** optional-content dictionary enclosing this content, outermost
133    /// first.
134    ///
135    /// A visibility test scans the whole mark stack and any one entry can
136    /// veto, so nested `BDC /OC` sequences each get a say — which the
137    /// innermost alone does not capture. The same
138    /// two conditions apply to each: the tag is exactly `OC`, and the
139    /// properties came from the `/Properties` resource rather than being
140    /// written inline.
141    #[must_use]
142    pub fn optional_content_all(&self) -> Vec<&Dict> {
143        self.marks
144            .iter()
145            .filter(|m| m.tag.as_bytes() == b"OC" && m.from_resources)
146            .filter_map(|m| m.properties.as_deref())
147            .collect()
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    // Test fixtures quote the oracle's own vectors, compare floats exactly
154    // where the behaviour being pinned is exact, and index arrays whose
155    // length the fixture itself fixes.
156    #![allow(
157        clippy::unreadable_literal,
158        clippy::float_cmp,
159        clippy::indexing_slicing,
160        clippy::cast_precision_loss,
161        clippy::cast_possible_truncation,
162        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
163    )]
164
165    use super::ContentMarks;
166    use crate::ops::MarkProperties;
167    use pdfrum_object::{Dict, Name, Object};
168
169    fn mcid(n: i64) -> Dict {
170        Dict::from_pairs([(Name::from("MCID"), Object::Int(n))])
171    }
172
173    #[test]
174    fn emc_never_pops_past_the_bottom() {
175        let mut marks = ContentMarks::new();
176        // Every pop on an empty stack simply reports nothing happened.
177        assert!(!marks.pop());
178        assert!(!marks.pop());
179        assert!(marks.is_empty());
180        marks.push(Name::from("Span"));
181        assert!(marks.pop());
182        assert!(!marks.pop());
183    }
184
185    #[test]
186    fn an_unresolvable_named_property_list_pushes_nothing() {
187        let mut marks = ContentMarks::new();
188        let pushed = marks.push_with_properties(
189            Name::from("OC"),
190            &MarkProperties::Named(Name::from("MC0")),
191            |_| None,
192        );
193        assert!(!pushed);
194        assert!(marks.is_empty());
195    }
196
197    #[test]
198    fn the_first_mark_with_an_mcid_wins() {
199        let mut marks = ContentMarks::new();
200        marks.push(Name::from("Span"));
201        marks.push_with_properties(
202            Name::from("P"),
203            &MarkProperties::Inline(Box::new(mcid(7))),
204            |_| None,
205        );
206        marks.push_with_properties(
207            Name::from("Span"),
208            &MarkProperties::Inline(Box::new(mcid(9))),
209            |_| None,
210        );
211        assert_eq!(marks.content_id(), Some(7));
212    }
213
214    #[test]
215    fn an_inline_oc_dictionary_is_ignored_for_visibility() {
216        let mut marks = ContentMarks::new();
217        // Inline: not from `/Properties`, so it does not count.
218        marks.push_with_properties(
219            Name::from("OC"),
220            &MarkProperties::Inline(Box::new(Dict::new())),
221            |_| None,
222        );
223        assert!(marks.optional_content().is_none());
224
225        // The same tag resolved through `/Properties` does count.
226        let mut marks = ContentMarks::new();
227        marks.push_with_properties(
228            Name::from("OC"),
229            &MarkProperties::Named(Name::from("MC0")),
230            |_| Some(Dict::new()),
231        );
232        assert!(marks.optional_content().is_some());
233    }
234
235    #[test]
236    fn only_the_exact_oc_tag_counts() {
237        let mut marks = ContentMarks::new();
238        marks.push_with_properties(
239            Name::from("OCX"),
240            &MarkProperties::Named(Name::from("MC0")),
241            |_| Some(Dict::new()),
242        );
243        assert!(marks.optional_content().is_none());
244    }
245}