Skip to main content

pdfrum_doc/structure/
element.rs

1//! One node of the logical structure tree and the five shapes its `/K` takes.
2//!
3//! A structure element's kids are heterogeneous: other elements, marked
4//! content on a page, marked content in a named stream, a reference to a page
5//! object, or nothing at all. Which one a `/K` entry means depends on its
6//! type *and* on whether it belongs to the page currently being read — the
7//! same element yields different kids from different pages, which is how one
8//! logical tree spans a document.
9
10use pdfrum_object::{Dict, Name, ObjRef, Object, Resolve};
11
12use crate::names;
13
14/// One entry in a structure element's kid list.
15///
16/// A slot is always reserved, even when nothing usable sits in it, because
17/// the dump walks kid indices and a missing kid contributes a skipped index
18/// rather than shifting its siblings.
19#[derive(Debug, Clone, PartialEq)]
20pub enum Kid {
21    /// Nothing usable: absent, the wrong type, or content belonging to a
22    /// different page.
23    Invalid,
24    /// A nested structure element, identified by where it was found.
25    ///
26    /// Two indices live here and they are **different spaces**: `slot` is the
27    /// position in the parent's `/K` array and never changes, while `linked`
28    /// is an index into the tree's element table and is filled only once the
29    /// upward walk actually reaches that element. A kid the walk never
30    /// reached keeps `linked: None` and the dump skips it.
31    Element {
32        /// The element's dictionary.
33        dict: Dict,
34        /// Its object reference, when it has one.
35        reference: Option<ObjRef>,
36        /// Its position in the parent's `/K`, which distinguishes inline kids
37        /// that share no object number.
38        slot: usize,
39        /// Its index in the tree's element table, once linked.
40        linked: Option<usize>,
41    },
42    /// Marked content in the page's own content stream.
43    PageContent {
44        /// The `/MCID` this kid names.
45        content_id: i64,
46    },
47    /// Marked content in a stream other than the page's.
48    StreamContent {
49        /// The stream's object number, or zero when `/Stm` is not a
50        /// reference.
51        stream_obj_num: u32,
52        /// The `/MCID` this kid names.
53        content_id: i64,
54    },
55    /// A reference to a page object rather than to content.
56    Object {
57        /// The referenced object's number, or zero when `/Obj` is not a
58        /// reference.
59        obj_num: u32,
60    },
61}
62
63/// A logical structure element.
64#[derive(Debug, Clone, PartialEq)]
65pub struct StructElement {
66    /// The element's own dictionary.
67    pub dict: Dict,
68    /// Its object reference, when it is indirect.
69    pub reference: Option<ObjRef>,
70    /// `/S` after the role map has been applied once, at load time. The raw
71    /// `/S` is not retained.
72    pub kind: Vec<u8>,
73    /// The kid slots, in `/K` order.
74    pub kids: Vec<Kid>,
75    /// The parent element's index in the tree's element table, once the
76    /// bottom-up walk has linked it.
77    pub parent: Option<usize>,
78}
79
80impl StructElement {
81    /// Reads an element's kids against a page.
82    ///
83    /// `page_obj_num` is the page being read; content kids that name a
84    /// different page become `Kid::Invalid`, while element kids are never
85    /// page-tested at all — that asymmetry is what lets one element tree be
86    /// reachable from every page it touches.
87    pub fn load_kids<R: Resolve>(dict: &Dict, page_obj_num: u32, r: &R) -> Vec<Kid> {
88        // The element's own `/Pg`, which each kid may override.
89        let own_page = dict
90            .raw(names::PG)
91            .and_then(Object::as_ref_id)
92            .map_or(0, |reference| reference.num);
93
94        let Some(kids_obj) = dict.get(names::K, r).map(|k| k.get().clone()) else {
95            return Vec::new();
96        };
97        match &kids_obj {
98            Object::Array(array) => array
99                .iter()
100                .enumerate()
101                .map(|(slot, element)| {
102                    let direct = element.resolve(r).ok().map(|d| d.get().clone());
103                    load_kid(direct.as_ref(), element, own_page, page_obj_num, slot, r)
104                })
105                .collect(),
106            other => vec![load_kid(Some(other), other, own_page, page_obj_num, 0, r)],
107        }
108    }
109
110    /// Points a kid slot at an element the walk has now built.
111    ///
112    /// Answers whether any slot matched — a question, not a refused mutation.
113    /// Matching is by object reference when both sides have one, and by
114    /// dictionary equality otherwise, which is how an inline kid dictionary —
115    /// having no object number to compare — still finds its slot.
116    pub fn link_kid(&mut self, target: &Dict, reference: Option<ObjRef>, element: usize) -> bool {
117        let mut matched = false;
118        for kid in &mut self.kids {
119            if let Kid::Element {
120                dict,
121                reference: kid_ref,
122                linked,
123                ..
124            } = kid
125            {
126                let same = match (*kid_ref, reference) {
127                    (Some(a), Some(b)) => a == b,
128                    _ => dict == target,
129                };
130                if same {
131                    *linked = Some(element);
132                    matched = true;
133                }
134            }
135        }
136        matched
137    }
138
139    /// `/Type`, coerced from whatever the key holds. Usually `StructElem`.
140    #[must_use]
141    pub fn obj_type<R: Resolve>(&self, r: &R) -> Vec<u8> {
142        self.dict.byte_string(names::TYPE, r).unwrap_or_default()
143    }
144
145    /// `/Alt`, the alternate description.
146    #[must_use]
147    pub fn alt_text<R: Resolve>(&self, r: &R) -> String {
148        self.dict.text(names::ALT, r).unwrap_or_default()
149    }
150
151    /// `/ActualText`, the exact replacement text.
152    #[must_use]
153    pub fn actual_text<R: Resolve>(&self, r: &R) -> String {
154        self.dict.text(names::ACTUAL_TEXT, r).unwrap_or_default()
155    }
156
157    /// `/E`, an abbreviation's expansion.
158    #[must_use]
159    pub fn expansion<R: Resolve>(&self, r: &R) -> String {
160        self.dict.text(names::E, r).unwrap_or_default()
161    }
162
163    /// `/T`, the element's title.
164    #[must_use]
165    pub fn title<R: Resolve>(&self, r: &R) -> String {
166        self.dict.text(names::T, r).unwrap_or_default()
167    }
168
169    /// `/ID`, read **without resolving** and filtered to a string.
170    ///
171    /// The `Option` is load-bearing: a present-but-empty `/ID` differs from
172    /// an absent one at the API boundary, even though the dump prints neither.
173    #[must_use]
174    pub fn id(&self) -> Option<Vec<u8>> {
175        self.dict
176            .raw(names::ID)
177            .and_then(Object::as_string)
178            .map(|s| s.bytes.to_vec())
179    }
180
181    /// `/Lang`, read without resolving and filtered to a string.
182    ///
183    /// **Not inherited** — a row inside a table with `/Lang /hu` reports
184    /// nothing of its own.
185    #[must_use]
186    pub fn lang(&self) -> Option<Vec<u8>> {
187        self.dict
188            .raw(names::LANG)
189            .and_then(Object::as_string)
190            .map(|s| s.bytes.to_vec())
191    }
192
193    /// The `/MCID` a kid names, or `None` for a kid that is not content.
194    ///
195    /// This is the **page-filtered** reading: a content kid belonging to
196    /// another page came in as `Kid::Invalid` and answers `None` here. The
197    /// dump uses a different, unfiltered accessor
198    /// (`marked_content_id_count`).
199    ///
200    /// A real `/MCID` is non-negative (ISO 32000-1 §14.7.4.2), so a `Some`
201    /// is always a value the file carries.
202    #[must_use]
203    pub fn kid_content_id(&self, index: usize) -> Option<i64> {
204        match self.kids.get(index) {
205            Some(Kid::PageContent { content_id } | Kid::StreamContent { content_id, .. }) => {
206                Some(*content_id)
207            }
208            _ => None,
209        }
210    }
211}
212
213/// Classifies one `/K` entry.
214fn load_kid<R: Resolve>(
215    direct: Option<&Object>,
216    raw: &Object,
217    own_page: u32,
218    page_obj_num: u32,
219    slot: usize,
220    r: &R,
221) -> Kid {
222    let Some(direct) = direct else {
223        return Kid::Invalid;
224    };
225    match direct {
226        Object::Int(_) | Object::Real(_) => {
227            if own_page == page_obj_num {
228                Kid::PageContent {
229                    content_id: direct.as_int().unwrap_or(0),
230                }
231            } else {
232                Kid::Invalid
233            }
234        }
235        Object::Dict(dict) => {
236            // A kid's own `/Pg` overrides the element's, and is consulted
237            // *before* the type test, so a marked-content reference is judged
238            // against the page it names rather than its parent's.
239            let page = dict
240                .raw(names::PG)
241                .and_then(Object::as_ref_id)
242                .map_or(own_page, |reference| reference.num);
243            let kind = dict.name(names::TYPE);
244            match kind {
245                Some(k) if k == names::MCR => {
246                    if page != page_obj_num {
247                        return Kid::Invalid;
248                    }
249                    Kid::StreamContent {
250                        stream_obj_num: dict
251                            .raw(names::STM)
252                            .and_then(Object::as_ref_id)
253                            .map_or(0, |reference| reference.num),
254                        content_id: dict.int(names::MCID, r).unwrap_or(0),
255                    }
256                }
257                Some(k) if k == names::OBJR => {
258                    if page != page_obj_num {
259                        return Kid::Invalid;
260                    }
261                    Kid::Object {
262                        obj_num: dict
263                            .raw(names::OBJ)
264                            .and_then(Object::as_ref_id)
265                            .map_or(0, |reference| reference.num),
266                    }
267                }
268                // Any other `/Type`, including none, is a nested element —
269                // and element kids cross pages freely.
270                _ => Kid::Element {
271                    dict: dict.clone(),
272                    reference: raw.as_ref_id(),
273                    slot,
274                    linked: None,
275                },
276            }
277        }
278        _ => Kid::Invalid,
279    }
280}
281
282/// The **unfiltered** marked-content count the `--show-structure` dump reads.
283///
284/// Deliberately different from [`StructElement::kid_content_id`]: it ignores
285/// the page entirely, counts a bare number or dictionary as one, and answers
286/// `None` for an absent or unusable `/K`.
287#[must_use]
288pub(crate) fn marked_content_id_count<R: Resolve>(dict: &Dict, r: &R) -> Option<usize> {
289    match dict.get(names::K, r).map(|k| k.get().clone()) {
290        Some(Object::Int(_) | Object::Real(_) | Object::Dict(_)) => Some(1),
291        Some(Object::Array(array)) => Some(array.len()),
292        // An absent `/K` and an unusable one both mean "no marked content".
293        None | Some(_) => None,
294    }
295}
296
297/// The **unfiltered** marked-content identifier at one index, or `None`.
298///
299/// A real `/MCID` is non-negative (ISO 32000-1 §14.7.4.2), so a `Some` is
300/// always a value the file carries.
301#[must_use]
302pub(crate) fn marked_content_id_at<R: Resolve>(dict: &Dict, index: usize, r: &R) -> Option<i64> {
303    match dict.get(names::K, r).map(|k| k.get().clone()) {
304        Some(obj @ (Object::Int(_) | Object::Real(_))) => {
305            if index == 0 {
306                obj.as_int()
307            } else {
308                None
309            }
310        }
311        // A dictionary answers the same identifier at every index.
312        Some(Object::Dict(dict)) => mcid_from_dict(&dict, r),
313        Some(Object::Array(array)) => match array.get(index, r).map(|e| e.get().clone()) {
314            Some(obj @ (Object::Int(_) | Object::Real(_))) => obj.as_int(),
315            Some(Object::Dict(dict)) => mcid_from_dict(&dict, r),
316            _ => None,
317        },
318        _ => None,
319    }
320}
321
322/// A marked-content reference's identifier: `/Type` must be the **name**
323/// `MCR` and `/MCID` must be a number.
324fn mcid_from_dict<R: Resolve>(dict: &Dict, r: &R) -> Option<i64> {
325    if dict.name(names::TYPE) != Some(names::MCR) {
326        return None;
327    }
328    dict.get(names::MCID, r)
329        .and_then(|v| v.get().as_number().and_then(Object::as_int))
330}
331
332/// Applies the tree's `/RoleMap` to a structure type, once.
333#[must_use]
334pub fn map_role(role_map: Option<&Dict>, kind: &[u8]) -> Vec<u8> {
335    role_map
336        .and_then(|map| map.name(&Name::new(kind.to_vec())))
337        .filter(|mapped| !mapped.as_bytes().is_empty())
338        .map_or_else(|| kind.to_vec(), |mapped| mapped.as_bytes().to_vec())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::{Kid, StructElement, map_role, marked_content_id_at, marked_content_id_count};
344    use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
345
346    use crate::names;
347
348    fn dict(pairs: &[(&str, Object)]) -> Dict {
349        Dict::from_pairs(
350            pairs
351                .iter()
352                .map(|(k, v)| (Name::from(*k), v.clone()))
353                .collect::<Vec<_>>(),
354        )
355    }
356
357    #[test]
358    fn a_number_kid_is_page_content_only_for_its_own_page() {
359        let element = dict(&[("K", Object::Int(7))]);
360        // The element has no `/Pg`, so it reads as page zero.
361        assert_eq!(
362            StructElement::load_kids(&element, 0, &NoResolve),
363            vec![Kid::PageContent { content_id: 7 }]
364        );
365        assert_eq!(
366            StructElement::load_kids(&element, 4, &NoResolve),
367            vec![Kid::Invalid]
368        );
369    }
370
371    #[test]
372    fn a_dict_kid_with_no_recognized_type_is_a_nested_element() {
373        let inner = dict(&[("S", Object::Name(Name::from("P")))]);
374        let element = dict(&[("K", Object::Dict(inner.clone()))]);
375        let kids = StructElement::load_kids(&element, 0, &NoResolve);
376        assert_eq!(
377            kids,
378            vec![Kid::Element {
379                dict: inner,
380                reference: None,
381                slot: 0,
382                linked: None,
383            }]
384        );
385    }
386
387    #[test]
388    fn an_element_kid_is_never_page_tested() {
389        let inner = dict(&[("S", Object::Name(Name::from("P")))]);
390        let element = dict(&[("K", Object::Dict(inner))]);
391        // Page 9 is not this element's page, yet the kid survives.
392        assert!(matches!(
393            StructElement::load_kids(&element, 9, &NoResolve).as_slice(),
394            [Kid::Element { .. }]
395        ));
396    }
397
398    #[test]
399    fn a_marked_content_reference_needs_its_page_to_match() {
400        let mcr = dict(&[
401            ("Type", Object::Name(names::MCR.clone())),
402            ("MCID", Object::Int(3)),
403        ]);
404        let element = dict(&[("K", Object::Dict(mcr))]);
405        assert_eq!(
406            StructElement::load_kids(&element, 0, &NoResolve),
407            vec![Kid::StreamContent {
408                stream_obj_num: 0,
409                content_id: 3,
410            }]
411        );
412        assert_eq!(
413            StructElement::load_kids(&element, 1, &NoResolve),
414            vec![Kid::Invalid]
415        );
416    }
417
418    #[test]
419    fn the_unfiltered_count_ignores_the_page_and_answers_none_when_absent() {
420        assert_eq!(marked_content_id_count(&Dict::new(), &NoResolve), None);
421        assert_eq!(
422            marked_content_id_count(&dict(&[("K", Object::Int(0))]), &NoResolve),
423            Some(1)
424        );
425        assert_eq!(
426            marked_content_id_count(
427                &dict(&[(
428                    "K",
429                    Object::Array(Array::of([Object::Int(2), Object::Int(3)]))
430                )]),
431                &NoResolve
432            ),
433            Some(2)
434        );
435        assert_eq!(
436            marked_content_id_count(
437                &dict(&[("K", Object::Str(PdfString::literal(b"nope")))]),
438                &NoResolve
439            ),
440            None
441        );
442    }
443
444    #[test]
445    fn a_marked_content_reference_needs_the_name_type_and_a_number_mcid() {
446        let good = dict(&[(
447            "K",
448            Object::Dict(dict(&[
449                ("Type", Object::Name(names::MCR.clone())),
450                ("MCID", Object::Int(5)),
451            ])),
452        )]);
453        assert_eq!(marked_content_id_at(&good, 0, &NoResolve), Some(5));
454        // A dictionary answers at every index, not only zero.
455        assert_eq!(marked_content_id_at(&good, 9, &NoResolve), Some(5));
456
457        let wrong_type = dict(&[(
458            "K",
459            Object::Dict(dict(&[
460                ("Type", Object::Name(Name::from("Other"))),
461                ("MCID", Object::Int(5)),
462            ])),
463        )]);
464        assert_eq!(marked_content_id_at(&wrong_type, 0, &NoResolve), None);
465    }
466
467    #[test]
468    fn linking_a_kid_fills_its_element_index_and_leaves_its_slot_alone() {
469        // The two indices live in different spaces, and conflating them is
470        // how a kid ends up pointing back at an ancestor — which the dump
471        // then follows until the stack runs out.
472        let inner = dict(&[("S", Object::Name(Name::from("P")))]);
473        let mut element = StructElement {
474            dict: Dict::new(),
475            reference: None,
476            kind: b"Document".to_vec(),
477            kids: StructElement::load_kids(
478                &dict(&[("K", Object::Dict(inner.clone()))]),
479                0,
480                &NoResolve,
481            ),
482            parent: None,
483        };
484        assert!(element.link_kid(&inner, None, 7));
485        assert_eq!(
486            element.kids,
487            vec![Kid::Element {
488                dict: inner,
489                reference: None,
490                // Still slot zero of the parent's `/K` ...
491                slot: 0,
492                // ... while the element table index is the one just given.
493                linked: Some(7),
494            }]
495        );
496    }
497
498    #[test]
499    fn an_unmatched_kid_keeps_no_element_index_at_all() {
500        let inner = dict(&[("S", Object::Name(Name::from("P")))]);
501        let mut element = StructElement {
502            dict: Dict::new(),
503            reference: None,
504            kind: b"Document".to_vec(),
505            kids: StructElement::load_kids(&dict(&[("K", Object::Dict(inner))]), 0, &NoResolve),
506            parent: None,
507        };
508        let stranger = dict(&[("S", Object::Name(Name::from("Span")))]);
509        assert!(!element.link_kid(&stranger, None, 7));
510        assert!(matches!(
511            element.kids.first(),
512            Some(Kid::Element { linked: None, .. })
513        ));
514    }
515
516    #[test]
517    fn the_role_map_replaces_a_type_only_when_it_names_a_nonempty_one() {
518        let map = dict(&[("Quote", Object::Name(Name::from("BlockQuote")))]);
519        assert_eq!(map_role(Some(&map), b"Quote"), b"BlockQuote");
520        assert_eq!(map_role(Some(&map), b"P"), b"P");
521        assert_eq!(map_role(None, b"P"), b"P");
522    }
523}