Skip to main content

pdfrum_doc/structure/
dump.rs

1//! The `--show-structure` output format.
2//!
3//! This lives beside the tree rather than in `pdfrum-tool` because the
4//! ordering rules *are* behavior: which fields print, in which order, at what
5//! indent, and — the one that surprises — with **sorted** attribute keys. A
6//! dictionary keeps insertion order everywhere else in this workspace, but
7//! the API this dump goes through hands back the *i*-th key of a `std::map`,
8//! so the golden files are alphabetical. Sorting happens here and at the one
9//! other exposed site, never in storage.
10//!
11//! Indentation is `2·depth + 1` spaces: the format string writes `depth·2`
12//! spaces through a width specifier and then one literal space of its own.
13
14use std::fmt::Write as _;
15
16use pdfrum_common::PageIndex;
17use pdfrum_object::{Dict, Object, Resolve};
18
19use crate::names;
20use crate::structure::{StructTree, element};
21
22/// Renders one page's structure tree.
23///
24/// A document with no tree contributes **nothing at all** — not even a
25/// header — which is why most of the corpus's structure goldens are empty
26/// files. A tree that loaded but reached no elements still prints its header
27/// and the two trailing blank lines.
28#[must_use]
29pub fn render<R: Resolve>(
30    tree: Option<&StructTree>,
31    page_index: impl Into<PageIndex>,
32    r: &R,
33) -> String {
34    let page_index = page_index.into();
35    let Some(tree) = tree else {
36        return String::new();
37    };
38    let mut out = format!("Structure Tree for Page {page_index}\n");
39    for index in tree.top.iter().flatten() {
40        dump_element(&mut out, tree, *index, 0, r);
41    }
42    out.push_str("\n\n");
43    out
44}
45
46/// One element and its subtree.
47fn dump_element<R: Resolve>(
48    out: &mut String,
49    tree: &StructTree,
50    index: usize,
51    depth: usize,
52    r: &R,
53) {
54    let Some(element) = tree.elements.get(index) else {
55        return;
56    };
57    let pad = " ".repeat(depth * 2);
58
59    // 1. The structure type, after the role map.
60    if !element.kind.is_empty() {
61        let _ = writeln!(
62            out,
63            "{pad} S: {}",
64            wide(&String::from_utf8_lossy(&element.kind))
65        );
66    }
67
68    // 2. Attribute objects, each dumped two spaces further in.
69    for (attr_index, attr) in attributes(&element.dict, r).into_iter().enumerate() {
70        let _ = writeln!(out, "{pad} A[{attr_index}]:");
71        dump_attribute(out, &attr, depth * 2 + 2, r);
72    }
73
74    // 3-6. The text fields, each suppressed when empty.
75    for (label, value) in [
76        ("ActualText", element.actual_text(r)),
77        ("AltText", element.alt_text(r)),
78    ] {
79        if !value.is_empty() {
80            let _ = writeln!(out, "{pad} {label}: {}", wide(&value));
81        }
82    }
83    // Presence, not emptiness, is what gates these two: the length the
84    // emitter tests is a UTF-16 **byte** count including the terminator, so
85    // a present-but-empty `/ID` measures two and prints a bare `ID: `, while
86    // an absent one measures zero and prints nothing.
87    if let Some(id) = element.id() {
88        let _ = writeln!(out, "{pad} ID: {}", wide(&decoded(&id)));
89    }
90    if let Some(lang) = element.lang() {
91        let _ = writeln!(out, "{pad} Lang: {}", wide(&decoded(&lang)));
92    }
93
94    // 7. Marked-content identifiers, through the *unfiltered* accessor: the
95    // dump reports content on other pages too, which the page-filtered kid
96    // list deliberately does not.
97    // No `/K` at all means no loop.
98    for index in 0..element::marked_content_id_count(&element.dict, r).unwrap_or(0) {
99        if let Some(id) = element::marked_content_id_at(&element.dict, index, r) {
100            let _ = writeln!(out, "{pad} MCID{index}: {id}");
101        }
102    }
103
104    // 8. The parent's identifier, only when the parent has one and it
105    // carries an `/ID` key at all.
106    if let Some(parent) = element.parent.and_then(|p| tree.elements.get(p))
107        && let Some(id) = parent.id()
108    {
109        let _ = writeln!(out, "{pad} Parent ID: {}", wide(&decoded(&id)));
110    }
111
112    // 9-10. The title, then `/Type` — usually the literal `StructElem`.
113    let title = element.title(r);
114    if !title.is_empty() {
115        let _ = writeln!(out, "{pad} Title: {}", wide(&title));
116    }
117    let obj_type = element.obj_type(r);
118    if !obj_type.is_empty() {
119        let _ = writeln!(
120            out,
121            "{pad} Type: {}",
122            wide(&String::from_utf8_lossy(&obj_type))
123        );
124    }
125
126    // 11. The kids, skipping the slots the upward walk never linked.
127    for kid in &element.kids {
128        if let crate::structure::Kid::Element {
129            linked: Some(child),
130            ..
131        } = kid
132        {
133            dump_element(out, tree, *child, depth + 1, r);
134        }
135    }
136}
137
138/// The element's attribute objects.
139///
140/// `/A` resolves, an array contributes its dictionary-valued elements at
141/// their own indices, a bare dictionary contributes itself, and anything else
142/// contributes none — which is what makes the enclosing `for` loop's body
143/// never run.
144fn attributes<R: Resolve>(dict: &Dict, r: &R) -> Vec<Dict> {
145    match dict.get(names::A, r).map(|a| a.get().clone()) {
146        Some(Object::Array(array)) => (0..array.len())
147            .filter_map(|index| array.dict_at(index, r))
148            .collect(),
149        Some(Object::Dict(attr)) => vec![attr],
150        _ => Vec::new(),
151    }
152}
153
154/// One attribute dictionary's keys and values, **alphabetically**.
155fn dump_attribute<R: Resolve>(out: &mut String, attr: &Dict, indent: usize, r: &R) {
156    let mut keys: Vec<_> = attr.keys().map(pdfrum_object::Name::as_bytes).collect();
157    keys.sort_unstable();
158    for key in keys {
159        let name = pdfrum_object::Name::new(key.to_vec());
160        let value = attr.get(&name, r).map(|v| v.get().clone());
161        dump_value(out, &String::from_utf8_lossy(key), value.as_ref(), indent);
162    }
163}
164
165/// One attribute value, per its type.
166///
167/// Takes no resolver: every value reaching it has already been resolved by
168/// its caller, and an array's children are deliberately read **without**
169/// resolving, which is why a reference inside one reports its type tag rather
170/// than what it points at.
171fn dump_value(out: &mut String, name: &str, value: Option<&Object>, indent: usize) {
172    let pad = " ".repeat(indent);
173    match value {
174        Some(Object::Bool(flag)) => {
175            let _ = writeln!(out, "{pad} {name}: {}", u8::from(*flag));
176        }
177        // Six decimals, always: an integer 2 prints as `2.000000`.
178        Some(obj @ (Object::Int(_) | Object::Real(_))) => {
179            let number = f64::from(obj.number().unwrap_or(0.0));
180            let _ = writeln!(out, "{pad} {name}: {number:.6}");
181        }
182        Some(Object::Str(text)) => {
183            let _ = writeln!(out, "{pad} {name}: {}", wide(&text.as_text()));
184        }
185        Some(Object::Name(named)) => {
186            let _ = writeln!(out, "{pad} {name}: {}", wide(&named.as_text()));
187        }
188        // An array prints a bare label, then every child two spaces deeper
189        // **under the same name** — so a `/BBox` of four numbers shows as
190        // `BBox:` followed by four `BBox:` lines.
191        Some(Object::Array(array)) => {
192            let _ = writeln!(out, "{pad} {name}:");
193            for index in 0..array.len() {
194                // The child accessor does **not** resolve, so an array of
195                // references dumps each element as a reference.
196                dump_value(out, name, array.raw_at(index), indent + 2);
197            }
198        }
199        None | Some(Object::Null) => {
200            let _ = writeln!(out, "{pad} {name}: FPDF_OBJECT_UNKNOWN");
201        }
202        // A reference, stream or dictionary reaches the fall-through arm,
203        // which reports the numeric type tag rather than the value.
204        Some(other) => {
205            let _ = writeln!(
206                out,
207                "{pad} {name}: NOT_YET_IMPLEMENTED: {}",
208                type_tag(other)
209            );
210        }
211    }
212}
213
214/// The public API's numeric type tag, which the fall-through arm prints.
215fn type_tag(object: &Object) -> u8 {
216    match object {
217        Object::Null => 1,
218        Object::Bool(_) => 2,
219        Object::Int(_) | Object::Real(_) => 3,
220        Object::Str(_) => 4,
221        Object::Name(_) => 5,
222        Object::Array(_) => 6,
223        Object::Dict(_) => 7,
224        Object::Stream(_) => 9,
225        Object::Ref(_) => 8,
226    }
227}
228
229/// Decodes a string the way the dump's text pipeline does.
230fn decoded(bytes: &[u8]) -> String {
231    pdfrum_object::decode_text(bytes).into_owned()
232}
233
234/// Truncates a string where the oracle's wide-character output gives up.
235///
236/// The path is UTF-16 → `wchar_t[]` → `printf("%ls")`, which ends at the
237/// first **NUL** — a real code point a PDF string may legitimately contain —
238/// and glibc additionally abandons the conversion at any unit with no scalar
239/// value, cutting the line short there. Our strings are already
240/// scalar-valued, so a replacement character stands where a lone surrogate
241/// was; both it and a NUL end the line here.
242fn wide(text: &str) -> String {
243    match text.find(['\u{0}', '\u{FFFD}']) {
244        Some(at) => text.get(..at).unwrap_or_default().to_owned(),
245        None => text.to_owned(),
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::{attributes, dump_attribute, dump_value, render, wide};
252    use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
253
254    fn dict(pairs: &[(&str, Object)]) -> Dict {
255        Dict::from_pairs(
256            pairs
257                .iter()
258                .map(|(k, v)| (Name::from(*k), v.clone()))
259                .collect::<Vec<_>>(),
260        )
261    }
262
263    #[test]
264    fn an_absent_tree_prints_nothing_at_all() {
265        assert_eq!(render(None, 0, &NoResolve), "");
266    }
267
268    #[test]
269    fn an_empty_tree_still_prints_its_header_and_two_blank_lines() {
270        let tree = crate::structure::StructTree::default();
271        assert_eq!(
272            render(Some(&tree), 3, &NoResolve),
273            "Structure Tree for Page 3\n\n\n"
274        );
275    }
276
277    #[test]
278    fn attribute_keys_print_alphabetically_whatever_the_file_says() {
279        // Written Scope-then-O; the golden files are alphabetical.
280        let attr = dict(&[
281            ("Scope", Object::Name(Name::from("Row"))),
282            ("O", Object::Name(Name::from("Table"))),
283        ]);
284        let mut out = String::new();
285        dump_attribute(&mut out, &attr, 2, &NoResolve);
286        assert_eq!(out, "   O: Table\n   Scope: Row\n");
287    }
288
289    #[test]
290    fn a_number_always_prints_six_decimals() {
291        let mut out = String::new();
292        dump_value(&mut out, "ColSpan", Some(&Object::Int(2)), 0);
293        assert_eq!(out, " ColSpan: 2.000000\n");
294    }
295
296    #[test]
297    fn a_boolean_prints_as_one_or_zero() {
298        let mut out = String::new();
299        dump_value(&mut out, "CurUSD", Some(&Object::Bool(true)), 0);
300        assert_eq!(out, " CurUSD: 1\n");
301    }
302
303    #[test]
304    fn an_array_repeats_its_own_name_for_every_child() {
305        let bbox = Object::Array(Array::of([Object::from(44.0_f32), Object::from(45.0_f32)]));
306        let mut out = String::new();
307        dump_value(&mut out, "BBox", Some(&bbox), 0);
308        assert_eq!(out, " BBox:\n   BBox: 44.000000\n   BBox: 45.000000\n");
309    }
310
311    #[test]
312    fn a_reference_child_reports_its_type_tag_rather_than_its_value() {
313        let array = Object::Array(Array::of([Object::Ref(pdfrum_object::ObjRef::new(9, 0))]));
314        let mut out = String::new();
315        dump_value(&mut out, "RowSpan", Some(&array), 0);
316        assert_eq!(out, " RowSpan:\n   RowSpan: NOT_YET_IMPLEMENTED: 8\n");
317    }
318
319    #[test]
320    fn a_present_but_empty_string_attribute_still_prints_its_label() {
321        let mut out = String::new();
322        dump_value(
323            &mut out,
324            "Summary",
325            Some(&Object::Str(PdfString::literal(b""))),
326            0,
327        );
328        assert_eq!(out, " Summary: \n");
329    }
330
331    #[test]
332    fn a_non_dict_attribute_contributes_no_indices() {
333        assert!(attributes(&dict(&[("A", Object::Int(4))]), &NoResolve).is_empty());
334        assert_eq!(
335            attributes(&dict(&[("A", Object::Dict(Dict::new()))]), &NoResolve).len(),
336            1
337        );
338    }
339
340    #[test]
341    fn a_string_stops_at_the_first_nul_or_unconvertible_unit() {
342        assert_eq!(wide("plain"), "plain");
343        assert_eq!(wide("cut\u{FFFD}here"), "cut");
344        // A NUL is a real code point in a PDF string, and it ends the line.
345        assert_eq!(wide("Hello!\u{0}"), "Hello!");
346    }
347}