1use std::collections::{HashMap, HashSet};
35
36use zpdf_core::{ObjectId, PdfDict, PdfObject};
37use zpdf_parser::PdfFile;
38
39use crate::obj_util::{catalog_dict, resolve_dict, text};
40use crate::Catalog;
41
42const MAX_STRUCT_DEPTH: usize = 64;
45const MAX_STRUCT_ELEMENTS: usize = 500_000;
49const MAX_ROLE_MAP_DEPTH: usize = 32;
52const MAX_ROLE_MAP_ENTRIES: usize = 65_536;
55const MAX_TEXT_CHARS: usize = 64 * 1024;
58
59#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum StructRole {
64 Document,
66 Part,
67 Art,
68 Sect,
69 Div,
70 BlockQuote,
71 Caption,
72 Toc,
74 Toci,
76 Index,
77 NonStruct,
78 Private,
79 P,
81 H,
82 H1,
83 H2,
84 H3,
85 H4,
86 H5,
87 H6,
88 L,
90 Li,
92 Lbl,
94 LBody,
96 Table,
98 Tr,
100 Th,
102 Td,
104 THead,
106 TBody,
108 TFoot,
110 Span,
112 Quote,
113 Note,
114 Reference,
115 BibEntry,
116 Code,
117 Link,
118 Annot,
119 Ruby,
121 Rb,
123 Rt,
125 Rp,
127 Warichu,
128 Wt,
130 Wp,
132 Figure,
134 Formula,
135 Form,
136 Other(String),
139}
140
141impl StructRole {
142 fn from_name(name: &str) -> Self {
145 use StructRole::*;
146 match name {
147 "Document" => Document,
148 "Part" => Part,
149 "Art" => Art,
150 "Sect" => Sect,
151 "Div" => Div,
152 "BlockQuote" => BlockQuote,
153 "Caption" => Caption,
154 "TOC" => Toc,
155 "TOCI" => Toci,
156 "Index" => Index,
157 "NonStruct" => NonStruct,
158 "Private" => Private,
159 "P" => P,
160 "H" => H,
161 "H1" => H1,
162 "H2" => H2,
163 "H3" => H3,
164 "H4" => H4,
165 "H5" => H5,
166 "H6" => H6,
167 "L" => L,
168 "LI" => Li,
169 "Lbl" => Lbl,
170 "LBody" => LBody,
171 "Table" => Table,
172 "TR" => Tr,
173 "TH" => Th,
174 "TD" => Td,
175 "THead" => THead,
176 "TBody" => TBody,
177 "TFoot" => TFoot,
178 "Span" => Span,
179 "Quote" => Quote,
180 "Note" => Note,
181 "Reference" => Reference,
182 "BibEntry" => BibEntry,
183 "Code" => Code,
184 "Link" => Link,
185 "Annot" => Annot,
186 "Ruby" => Ruby,
187 "RB" => Rb,
188 "RT" => Rt,
189 "RP" => Rp,
190 "Warichu" => Warichu,
191 "WT" => Wt,
192 "WP" => Wp,
193 "Figure" => Figure,
194 "Formula" => Formula,
195 "Form" => Form,
196 other => Other(other.to_string()),
197 }
198 }
199
200 pub fn as_str(&self) -> &str {
203 use StructRole::*;
204 match self {
205 Document => "Document",
206 Part => "Part",
207 Art => "Art",
208 Sect => "Sect",
209 Div => "Div",
210 BlockQuote => "BlockQuote",
211 Caption => "Caption",
212 Toc => "TOC",
213 Toci => "TOCI",
214 Index => "Index",
215 NonStruct => "NonStruct",
216 Private => "Private",
217 P => "P",
218 H => "H",
219 H1 => "H1",
220 H2 => "H2",
221 H3 => "H3",
222 H4 => "H4",
223 H5 => "H5",
224 H6 => "H6",
225 L => "L",
226 Li => "LI",
227 Lbl => "Lbl",
228 LBody => "LBody",
229 Table => "Table",
230 Tr => "TR",
231 Th => "TH",
232 Td => "TD",
233 THead => "THead",
234 TBody => "TBody",
235 TFoot => "TFoot",
236 Span => "Span",
237 Quote => "Quote",
238 Note => "Note",
239 Reference => "Reference",
240 BibEntry => "BibEntry",
241 Code => "Code",
242 Link => "Link",
243 Annot => "Annot",
244 Ruby => "Ruby",
245 Rb => "RB",
246 Rt => "RT",
247 Rp => "RP",
248 Warichu => "Warichu",
249 Wt => "WT",
250 Wp => "WP",
251 Figure => "Figure",
252 Formula => "Formula",
253 Form => "Form",
254 Other(s) => s,
255 }
256 }
257
258 pub fn is_standard(&self) -> bool {
261 !matches!(self, StructRole::Other(_))
262 }
263
264 pub fn is_heading(&self) -> bool {
266 use StructRole::*;
267 matches!(self, H | H1 | H2 | H3 | H4 | H5 | H6)
268 }
269
270 pub fn is_block_level(&self) -> bool {
280 use StructRole::*;
281 matches!(
282 self,
283 Document
284 | Part
285 | Art
286 | Sect
287 | Div
288 | BlockQuote
289 | Caption
290 | Toc
291 | Toci
292 | Index
293 | P
294 | H
295 | H1
296 | H2
297 | H3
298 | H4
299 | H5
300 | H6
301 | L
302 | Li
303 | Table
304 | Tr
305 | Note
308 | Figure
309 | Formula
310 )
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum StructKid {
318 Element(StructElem),
320 MarkedContent {
324 page: Option<usize>,
326 mcid: i64,
328 },
329 Object {
332 page: Option<usize>,
334 obj: ObjectId,
336 },
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct StructElem {
342 pub role: StructRole,
344 pub raw_type: String,
347 pub title: Option<String>,
349 pub lang: Option<String>,
351 pub alt: Option<String>,
354 pub actual_text: Option<String>,
357 pub expansion: Option<String>,
359 pub page: Option<usize>,
362 pub kids: Vec<StructKid>,
364}
365
366impl StructElem {
367 pub fn accessible_text(&self) -> Option<&str> {
370 self.actual_text.as_deref().or(self.alt.as_deref())
371 }
372
373 pub fn child_elements(&self) -> impl Iterator<Item = &StructElem> {
375 self.kids.iter().filter_map(|k| match k {
376 StructKid::Element(e) => Some(e),
377 _ => None,
378 })
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct StructTree {
386 pub children: Vec<StructElem>,
388 pub marked: bool,
390}
391
392impl StructTree {
393 pub fn element_count(&self) -> usize {
395 fn count(e: &StructElem) -> usize {
396 1 + e.child_elements().map(count).sum::<usize>()
397 }
398 self.children.iter().map(count).sum()
399 }
400}
401
402pub fn is_tagged(file: &PdfFile) -> bool {
406 let Some(root) = catalog_dict(file) else {
407 return false;
408 };
409 let Some(mark_info) = resolve_dict(file, root.get("MarkInfo")) else {
410 return false;
411 };
412 matches!(mark_info.get("Marked"), Some(PdfObject::Bool(true)))
413}
414
415pub fn parse_struct_tree(file: &PdfFile, catalog: &Catalog) -> Option<StructTree> {
419 let root = catalog_dict(file)?;
420 let tree_root = resolve_dict(file, root.get("StructTreeRoot"))?;
421
422 let mut visited = HashSet::new();
423 if let Some(PdfObject::Ref(id)) = root.get("StructTreeRoot") {
427 visited.insert(*id);
428 }
429
430 let mut walk = StructWalk {
431 file,
432 catalog,
433 role_map: read_role_map(file, &tree_root),
434 visited,
435 budget: MAX_STRUCT_ELEMENTS,
436 };
437
438 let root_page = walk.page_of(&tree_root);
441 let mut children = Vec::new();
442 for kid in normalize_kids(file, &tree_root) {
443 if let Some(StructKid::Element(e)) = walk.parse_kid(&kid, root_page, 0) {
444 children.push(e);
445 }
446 }
448
449 Some(StructTree {
450 children,
451 marked: is_tagged(file),
452 })
453}
454
455struct StructWalk<'a> {
457 file: &'a PdfFile,
458 catalog: &'a Catalog,
459 role_map: HashMap<String, String>,
461 visited: HashSet<ObjectId>,
464 budget: usize,
466}
467
468impl StructWalk<'_> {
469 fn parse_kid(
473 &mut self,
474 obj: &PdfObject,
475 parent_page: Option<usize>,
476 depth: usize,
477 ) -> Option<StructKid> {
478 if self.budget == 0 {
479 return None;
480 }
481 self.budget -= 1;
482
483 match obj {
484 PdfObject::Integer(mcid) => Some(StructKid::MarkedContent {
486 page: parent_page,
487 mcid: *mcid,
488 }),
489
490 PdfObject::Ref(id) => {
493 let resolved = self.file.resolve(*id).ok()?;
494 let dict = resolved.as_dict().ok()?;
495 match kid_dict_kind(dict) {
496 KidKind::Mcr => self.marked_content(dict, parent_page),
497 KidKind::Objr => self.object_ref(dict, parent_page),
498 KidKind::Element => {
499 if !self.visited.insert(*id) {
501 return None;
502 }
503 self.element(dict, parent_page, depth)
504 .map(StructKid::Element)
505 }
506 }
507 }
508
509 PdfObject::Dict(dict) => match kid_dict_kind(dict) {
511 KidKind::Mcr => self.marked_content(dict, parent_page),
512 KidKind::Objr => self.object_ref(dict, parent_page),
513 KidKind::Element => self
514 .element(dict, parent_page, depth)
515 .map(StructKid::Element),
516 },
517
518 _ => None,
519 }
520 }
521
522 fn element(
524 &mut self,
525 dict: &PdfDict,
526 parent_page: Option<usize>,
527 depth: usize,
528 ) -> Option<StructElem> {
529 if depth > MAX_STRUCT_DEPTH {
530 return None;
531 }
532 let page = self.page_of(dict).or(parent_page);
534
535 let raw_type = self.file_name(dict, "S").unwrap_or_default();
536 let role = StructRole::from_name(&self.resolve_role(&raw_type));
537
538 let kids = normalize_kids(self.file, dict)
539 .iter()
540 .filter_map(|k| self.parse_kid(k, page, depth + 1))
541 .collect();
542
543 Some(StructElem {
544 role,
545 raw_type,
546 title: capped_text(self.file, dict, "T"),
547 lang: capped_text(self.file, dict, "Lang"),
548 alt: capped_text(self.file, dict, "Alt"),
549 actual_text: capped_text(self.file, dict, "ActualText"),
550 expansion: capped_text(self.file, dict, "E"),
551 page,
552 kids,
553 })
554 }
555
556 fn marked_content(&self, dict: &PdfDict, parent_page: Option<usize>) -> Option<StructKid> {
560 let mcid = int_value(dict.get("MCID"))?;
561 let page = self.page_of(dict).or(parent_page);
562 Some(StructKid::MarkedContent { page, mcid })
563 }
564
565 fn object_ref(&self, dict: &PdfDict, parent_page: Option<usize>) -> Option<StructKid> {
568 let obj = dict.get_ref("Obj").ok()?;
569 let page = self.page_of(dict).or(parent_page);
570 Some(StructKid::Object { page, obj })
571 }
572
573 fn page_of(&self, dict: &PdfDict) -> Option<usize> {
575 let pg = dict.get_ref("Pg").ok()?;
576 self.catalog.page_index_of(pg)
577 }
578
579 fn file_name(&self, dict: &PdfDict, key: &str) -> Option<String> {
581 crate::obj_util::name_value(self.file, dict, key)
582 }
583
584 fn resolve_role(&self, raw: &str) -> String {
587 let mut current = raw.to_string();
588 let mut seen = HashSet::new();
589 for _ in 0..MAX_ROLE_MAP_DEPTH {
590 if !seen.insert(current.clone()) {
591 break;
592 }
593 match self.role_map.get(¤t) {
594 Some(next) if next != ¤t => current = next.clone(),
595 _ => break,
596 }
597 }
598 current
599 }
600}
601
602enum KidKind {
604 Mcr,
606 Objr,
608 Element,
610}
611
612fn kid_dict_kind(dict: &PdfDict) -> KidKind {
616 match dict.get_name("Type") {
617 Ok("MCR") => return KidKind::Mcr,
618 Ok("OBJR") => return KidKind::Objr,
619 Ok("StructElem") => return KidKind::Element,
620 _ => {}
621 }
622 if dict.get("S").is_none() {
625 if dict.get("MCID").is_some() {
626 return KidKind::Mcr;
627 }
628 if dict.get("Obj").is_some() {
629 return KidKind::Objr;
630 }
631 }
632 KidKind::Element
633}
634
635fn normalize_kids(file: &PdfFile, dict: &PdfDict) -> Vec<PdfObject> {
641 match dict.get("K") {
642 Some(PdfObject::Array(a)) => a.clone(),
643 Some(PdfObject::Ref(r)) => match file.resolve(*r) {
644 Ok(PdfObject::Array(a)) => a,
645 Ok(_) => vec![PdfObject::Ref(*r)],
647 Err(_) => Vec::new(),
648 },
649 Some(other) => vec![other.clone()],
650 None => Vec::new(),
651 }
652}
653
654fn read_role_map(file: &PdfFile, tree_root: &PdfDict) -> HashMap<String, String> {
657 let mut map = HashMap::new();
658 let Some(rm) = resolve_dict(file, tree_root.get("RoleMap")) else {
659 return map;
660 };
661 for (key, value) in rm.0.iter() {
662 if map.len() >= MAX_ROLE_MAP_ENTRIES {
663 break;
664 }
665 if let PdfObject::Name(n) = value {
666 map.insert(key.as_str().to_string(), n.as_str().to_string());
667 }
668 }
669 map
670}
671
672fn int_value(obj: Option<&PdfObject>) -> Option<i64> {
675 match obj? {
676 PdfObject::Integer(n) => Some(*n),
677 PdfObject::Real(f) if f.is_finite() && f.fract() == 0.0 => Some(*f as i64),
678 _ => None,
679 }
680}
681
682fn capped_text(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
685 match text(file, dict, key) {
686 Some(s) if s.chars().count() > MAX_TEXT_CHARS => {
687 Some(s.chars().take(MAX_TEXT_CHARS).collect())
688 }
689 other => other,
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::test_util::build_pdf;
697 use crate::PdfDocument;
698
699 const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
700 const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
701
702 fn open(objects: &[&str]) -> PdfDocument {
703 PdfDocument::open(build_pdf(objects)).expect("open pdf")
704 }
705
706 fn doc(catalog: &str, extra: &[&str]) -> PdfDocument {
709 let mut objs = vec![catalog, PAGES, PAGE];
710 objs.extend_from_slice(extra);
711 open(&objs)
712 }
713
714 #[test]
715 fn no_struct_tree_is_none() {
716 let d = doc("<< /Type /Catalog /Pages 2 0 R >>", &[]);
717 assert!(d.struct_tree().is_none());
718 assert!(!d.is_tagged());
719 }
720
721 #[test]
722 fn mark_info_marks_tagged() {
723 let d = doc(
724 "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> >>",
725 &[],
726 );
727 assert!(d.is_tagged());
728 assert!(d.struct_tree().is_none());
730 }
731
732 #[test]
733 fn simple_document_paragraph_with_mcids() {
734 let d = doc(
736 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R \
737 /MarkInfo << /Marked true >> >>",
738 &[
739 "<< /Type /StructTreeRoot /K 5 0 R >>",
740 "<< /Type /StructElem /S /Document /P 4 0 R /K 6 0 R >>",
741 "<< /Type /StructElem /S /P /P 5 0 R /Pg 3 0 R /K [0 1] >>",
742 ],
743 );
744 let tree = d.struct_tree().expect("tree");
745 assert!(tree.marked);
746 assert_eq!(tree.children.len(), 1);
747 let document = &tree.children[0];
748 assert_eq!(document.role, StructRole::Document);
749 assert_eq!(document.kids.len(), 1);
750
751 let para = document.child_elements().next().unwrap();
752 assert_eq!(para.role, StructRole::P);
753 assert_eq!(para.page, Some(0));
754 assert_eq!(
755 para.kids,
756 vec![
757 StructKid::MarkedContent {
758 page: Some(0),
759 mcid: 0
760 },
761 StructKid::MarkedContent {
762 page: Some(0),
763 mcid: 1
764 },
765 ]
766 );
767 assert_eq!(tree.element_count(), 2);
768 }
769
770 #[test]
771 fn role_map_resolves_custom_type() {
772 let d = doc(
774 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
775 &[
776 "<< /Type /StructTreeRoot /K 5 0 R /RoleMap << /Heading1 /H1 >> >>",
777 "<< /Type /StructElem /S /Heading1 /P 4 0 R >>",
778 ],
779 );
780 let tree = d.struct_tree().expect("tree");
781 let h = &tree.children[0];
782 assert_eq!(h.role, StructRole::H1);
783 assert!(h.role.is_heading());
784 assert_eq!(h.raw_type, "Heading1"); }
786
787 #[test]
788 fn unmapped_custom_type_is_other() {
789 let d = doc(
790 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
791 &[
792 "<< /Type /StructTreeRoot /K 5 0 R >>",
793 "<< /Type /StructElem /S /MyWidget /P 4 0 R >>",
794 ],
795 );
796 let role = &d.struct_tree().unwrap().children[0].role;
797 assert_eq!(role, &StructRole::Other("MyWidget".to_string()));
798 assert!(!role.is_standard());
799 assert_eq!(role.as_str(), "MyWidget");
800 }
801
802 #[test]
803 fn figure_alt_text_is_accessible() {
804 let d = doc(
805 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
806 &[
807 "<< /Type /StructTreeRoot /K 5 0 R >>",
808 "<< /Type /StructElem /S /Figure /P 4 0 R /Alt (A bar chart) >>",
809 ],
810 );
811 let fig = &d.struct_tree().unwrap().children[0];
812 assert_eq!(fig.role, StructRole::Figure);
813 assert_eq!(fig.alt.as_deref(), Some("A bar chart"));
814 assert_eq!(fig.accessible_text(), Some("A bar chart"));
815 }
816
817 #[test]
818 fn actual_text_preferred_over_alt() {
819 let d = doc(
820 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
821 &[
822 "<< /Type /StructTreeRoot /K 5 0 R >>",
823 "<< /Type /StructElem /S /Span /P 4 0 R /Alt (alt) /ActualText (exact) >>",
824 ],
825 );
826 let span = &d.struct_tree().unwrap().children[0];
827 assert_eq!(span.accessible_text(), Some("exact"));
828 }
829
830 #[test]
831 fn objr_kid_resolves_object_and_page() {
832 let d = doc(
834 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
835 &[
836 "<< /Type /StructTreeRoot /K 5 0 R >>",
837 "<< /Type /StructElem /S /Link /P 4 0 R \
838 /K << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >> >>",
839 "<< /Type /Annot /Subtype /Link >>",
840 ],
841 );
842 let link = &d.struct_tree().unwrap().children[0];
843 assert_eq!(link.role, StructRole::Link);
844 assert_eq!(link.kids.len(), 1);
845 match &link.kids[0] {
846 StructKid::Object { page, obj } => {
847 assert_eq!(*page, Some(0));
848 assert_eq!(obj.0, 6); }
850 other => panic!("expected OBJR kid, got {other:?}"),
851 }
852 }
853
854 #[test]
855 fn mcr_dict_kid_with_explicit_page() {
856 let d = doc(
857 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
858 &[
859 "<< /Type /StructTreeRoot /K 5 0 R >>",
860 "<< /Type /StructElem /S /P /P 4 0 R \
861 /K << /Type /MCR /Pg 3 0 R /MCID 7 >> >>",
862 ],
863 );
864 let para = &d.struct_tree().unwrap().children[0];
865 assert_eq!(
866 para.kids[0],
867 StructKid::MarkedContent {
868 page: Some(0),
869 mcid: 7
870 }
871 );
872 }
873
874 #[test]
875 fn page_inherited_from_ancestor() {
876 let d = doc(
878 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
879 &[
880 "<< /Type /StructTreeRoot /K 5 0 R >>",
881 "<< /Type /StructElem /S /Sect /P 4 0 R /Pg 3 0 R /K 6 0 R >>",
882 "<< /Type /StructElem /S /Span /P 5 0 R /K [9] >>",
883 ],
884 );
885 let span = d.struct_tree().unwrap().children[0]
886 .child_elements()
887 .next()
888 .unwrap()
889 .clone();
890 assert_eq!(span.page, Some(0), "inherited /Pg");
891 assert_eq!(
892 span.kids[0],
893 StructKid::MarkedContent {
894 page: Some(0),
895 mcid: 9
896 }
897 );
898 }
899
900 #[test]
901 fn single_ref_k_not_array() {
902 let d = doc(
904 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
905 &[
906 "<< /Type /StructTreeRoot /K 5 0 R >>",
907 "<< /Type /StructElem /S /Document /K 6 0 R >>",
908 "<< /Type /StructElem /S /P /P 5 0 R >>",
909 ],
910 );
911 let document = &d.struct_tree().unwrap().children[0];
912 assert_eq!(document.child_elements().count(), 1);
913 assert_eq!(
914 document.child_elements().next().unwrap().role,
915 StructRole::P
916 );
917 }
918
919 #[test]
920 fn cyclic_kids_terminate() {
921 let d = doc(
923 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
924 &[
925 "<< /Type /StructTreeRoot /K 5 0 R >>",
926 "<< /Type /StructElem /S /Document /K 6 0 R >>",
927 "<< /Type /StructElem /S /Sect /K 5 0 R >>",
928 ],
929 );
930 let tree = d.struct_tree().expect("tree (no hang)");
931 assert_eq!(tree.children.len(), 1);
933 let sect = tree.children[0].child_elements().next().unwrap();
934 assert_eq!(sect.role, StructRole::Sect);
935 assert_eq!(sect.child_elements().count(), 0);
936 }
937
938 #[test]
939 fn root_back_edge_makes_no_spurious_element() {
940 let d = doc(
943 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
944 &[
945 "<< /Type /StructTreeRoot /K 5 0 R >>",
946 "<< /Type /StructElem /S /Document /K 4 0 R >>",
947 ],
948 );
949 let document = &d.struct_tree().unwrap().children[0];
950 assert_eq!(document.role, StructRole::Document);
951 assert_eq!(document.child_elements().count(), 0, "root back-edge cut");
952 }
953
954 #[test]
955 fn role_map_cycle_terminates() {
956 let d = doc(
959 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
960 &[
961 "<< /Type /StructTreeRoot /K 5 0 R /RoleMap << /Foo /Bar /Bar /Foo >> >>",
962 "<< /Type /StructElem /S /Foo /P 4 0 R >>",
963 ],
964 );
965 let role = &d.struct_tree().expect("tree (no hang)").children[0].role;
966 assert!(!role.is_standard());
967 }
968
969 #[test]
970 fn deeply_nested_tree_terminates() {
971 let depth = MAX_STRUCT_DEPTH + 50;
974 let mut objs = vec![
975 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>".to_string(),
976 PAGES.to_string(),
977 PAGE.to_string(),
978 "<< /Type /StructTreeRoot /K 5 0 R >>".to_string(),
979 ];
980 for i in 0..depth {
982 let obj_num = 5 + i;
983 if i + 1 < depth {
984 objs.push(format!(
985 "<< /Type /StructElem /S /Div /K {} 0 R >>",
986 obj_num + 1
987 ));
988 } else {
989 objs.push("<< /Type /StructElem /S /Div >>".to_string());
990 }
991 }
992 let refs: Vec<&str> = objs.iter().map(|s| s.as_str()).collect();
993 let d = open(&refs);
994 assert!(d.struct_tree().is_some());
996 }
997
998 #[test]
999 fn huge_alt_text_is_capped() {
1000 let big = "A".repeat(MAX_TEXT_CHARS + 1000);
1001 let d = doc(
1002 "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
1003 &[
1004 "<< /Type /StructTreeRoot /K 5 0 R >>",
1005 &format!("<< /Type /StructElem /S /Figure /Alt ({big}) >>"),
1006 ],
1007 );
1008 let alt = d.struct_tree().unwrap().children[0].alt.clone().unwrap();
1009 assert_eq!(alt.chars().count(), MAX_TEXT_CHARS);
1010 }
1011
1012 #[test]
1013 fn role_name_round_trip() {
1014 for name in [
1015 "Document", "TOC", "TOCI", "P", "H1", "H6", "L", "LI", "Lbl", "LBody", "Table", "TR",
1016 "TH", "TD", "THead", "TBody", "TFoot", "Span", "BibEntry", "Link", "RB", "WP",
1017 "Figure", "Formula", "Form",
1018 ] {
1019 let role = StructRole::from_name(name);
1020 assert!(role.is_standard(), "{name} should be standard");
1021 assert_eq!(role.as_str(), name, "round-trip for {name}");
1022 }
1023 }
1024}