1use 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#[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
46fn 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 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 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 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 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 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 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 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 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
138fn 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
154fn 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
165fn 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 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 Some(Object::Array(array)) => {
192 let _ = writeln!(out, "{pad} {name}:");
193 for index in 0..array.len() {
194 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 Some(other) => {
205 let _ = writeln!(
206 out,
207 "{pad} {name}: NOT_YET_IMPLEMENTED: {}",
208 type_tag(other)
209 );
210 }
211 }
212}
213
214fn 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
229fn decoded(bytes: &[u8]) -> String {
231 pdfrum_object::decode_text(bytes).into_owned()
232}
233
234fn 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 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 assert_eq!(wide("Hello!\u{0}"), "Hello!");
346 }
347}