//! Structure-tree and accessibility acceptance tests. //! //! The merge criteria from `REVIEWS/adr/0011-pdf-structure-tree.md`. //! //! The most valuable test here is not about accessibility at all: //! [`bmc_no_longer_turns_red_into_green`] guards a colour-corruption bug //! that affected every tagged document. use std::path::PathBuf; use nigig_pdf_document::structure::{AccessibilityIssue, StructRole}; use nigig_pdf_document::PdfDocument; use nigig_pdf_graphics::content::parse_content_stream; use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand}; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } /// `BMC` never matched its dispatch arm, so it was neither recognised nor /// *cleared from the operand stack*. The leftover `/Tag` shifted the /// operands of every following operator: `1 0 0 rg` became /// `RgbFill(0.0, 1.0, 0.0)`. /// /// Tagged documents are exactly the ones containing `BMC`, so the documents /// that tried hardest to be accessible rendered with the wrong colours. #[test] fn bmc_no_longer_turns_red_into_green() { let bytes = corpus("structure/marked_content_colour.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let ops = parse_content_stream(&page.content_data).expect("content parses"); let cmds = RecordingDevice::from_ops(&ops); let fill = cmds .iter() .find_map(|c| match c { RenderCommand::SetFillColor(c) => Some(*c), _ => None, }) .expect("the page fills a rectangle"); assert!( (fill[0] - 1.0).abs() < 1e-9 && fill[1].abs() < 1e-9 && fill[2].abs() < 1e-9, "the page fills red, but got {fill:?} - BMC shifted the operands" ); } #[test] fn a_tagged_document_yields_its_tree() { let bytes = corpus("structure/tagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("structure tree"); assert!(!tree.untagged); assert!(tree.marked, "/MarkInfo /Marked true"); assert_eq!(tree.lang.as_deref(), Some("en-GB")); assert_eq!(tree.elements.len(), 4); let order = tree.reading_order(); let roles: Vec<&StructRole> = order.iter().map(|i| &tree.elements[*i].role).collect(); assert_eq!( roles, vec![ &StructRole::Document, &StructRole::Heading(Some(1)), &StructRole::Paragraph, &StructRole::Figure, ] ); } #[test] fn reading_order_is_depth_first_not_geometric() { let bytes = corpus("structure/tagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); let order = tree.reading_order(); // The Document root must precede all of its children. assert_eq!(tree.elements[order[0]].role, StructRole::Document); for i in &order[1..] { assert_eq!( tree.elements[*i].parent, Some(order[0]), "every other element is a child of the root here" ); } } #[test] fn mcids_link_content_to_its_structure_element() { let bytes = corpus("structure/tagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); // The heading owns MCID 0, the paragraph 1, the figure 2. for (mcid, expected) in [ (0, StructRole::Heading(Some(1))), (1, StructRole::Paragraph), (2, StructRole::Figure), ] { let el = tree .element_for_mcid(None, mcid) .unwrap_or_else(|| panic!("no element owns MCID {mcid}")); assert_eq!(el.role, expected, "MCID {mcid}"); } } /// The `/MCID` must survive all the way to the device, or the tree cannot /// be attached to what was actually drawn. #[test] fn mcids_reach_the_device() { let bytes = corpus("structure/tagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let ops = parse_content_stream(&page.content_data).expect("parses"); let cmds = RecordingDevice::from_ops(&ops); let mcids: Vec = cmds .iter() .filter_map(|c| match c { RenderCommand::BeginMarkedContent { mcid, .. } => *mcid, _ => None, }) .collect(); assert_eq!( mcids, vec![0, 1, 2], "every BDC's /MCID must reach the device" ); } #[test] fn alt_text_is_exposed() { let bytes = corpus("structure/tagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); let figure = tree .elements .iter() .find(|e| e.role == StructRole::Figure) .expect("a figure"); assert_eq!( figure.alt.as_deref(), Some("A red rectangle illustrating the layout") ); assert_eq!(figure.description(), figure.alt.as_deref()); } #[test] fn a_role_map_translates_custom_tags() { let bytes = corpus("structure/role_map.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); assert_eq!(tree.role_map.get("Chapter").map(String::as_str), Some("H1")); let chapter = tree .elements .iter() .find(|e| e.raw_type == "Chapter") .expect("the custom-tagged element"); assert_eq!( chapter.role, StructRole::Heading(Some(1)), "/RoleMap must map /Chapter onto /H1" ); // The original tag is kept, so a caller can see it was remapped. assert_eq!(chapter.raw_type, "Chapter"); let body = tree .elements .iter() .find(|e| e.raw_type == "BodyCopy") .expect("the second custom tag"); assert_eq!(body.role, StructRole::Paragraph); } #[test] fn accessibility_faults_are_each_reported() { let bytes = corpus("structure/inaccessible.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); let findings = tree.findings(); assert!( findings.contains(&AccessibilityIssue::NoDocumentLanguage), "{:?}", findings.issues ); assert!( findings .issues .iter() .any(|i| matches!(i, AccessibilityIssue::FigureWithoutAlt { .. })), "{:?}", findings.issues ); assert!( findings.issues.iter().any(|i| matches!( i, AccessibilityIssue::HeadingLevelSkipped { from: 1, to: 3, .. } )), "{:?}", findings.issues ); } #[test] fn a_well_formed_document_reports_no_issues() { let bytes = corpus("structure/tagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); let findings = tree.findings(); assert!( findings.no_issues_found(), "unexpected findings: {:?}", findings.issues ); } #[test] fn a_cell_outside_a_row_is_reported_on_a_real_document() { let bytes = corpus("structure/table.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); let findings = tree.findings(); assert!( findings .issues .iter() .any(|i| matches!(i, AccessibilityIssue::CellOutsideRow { .. })), "the un-rowed cell was not reported: {:?}", findings.issues ); } /// A `/K` graph pointing back at an ancestor must be cut, not expanded /// until the depth bound, and certainly not recursed until the stack dies. #[test] fn a_cyclic_structure_tree_terminates_and_is_reported() { let bytes = corpus("structure/cyclic.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("a cyclic tree must not error"); assert_eq!( tree.elements.len(), 2, "the cycle must be cut at first repeat, not expanded: {:?}", tree.elements.len() ); assert!( !tree.cycles_cut.is_empty(), "the cycle must be reported, not silently pruned" ); assert!(tree .findings() .issues .iter() .any(|i| matches!(i, AccessibilityIssue::CyclicStructure { .. }))); } /// An untagged document must report as untagged, which is a different state /// from a tagged document whose tree happens to be empty. #[test] fn an_untagged_document_says_so() { let bytes = corpus("structure/untagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); assert!(tree.untagged); assert!(tree.elements.is_empty()); assert_eq!( tree.findings().issues, vec![AccessibilityIssue::NotTagged], "an untagged document has one finding, not a pile of derived ones" ); } /// ADR 0011 rule 4: mechanical checks are not a conformance verdict. /// /// This asserts the *absence* of a conformance API. If someone adds an /// `is_pdf_ua` or similar, this test is where the argument happens. #[test] fn nothing_claims_pdf_ua_conformance() { let bytes = corpus("structure/tagged.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let tree = doc.structure_tree().expect("tree"); let findings = tree.findings(); // The only verdict offered is "these particular checks found nothing". assert!(findings.no_issues_found()); // Tripwire for a future rename. Comment lines are skipped so that the // module documentation can go on explaining *why* there is no // conformance verdict without tripping the check that enforces it. let source = include_str!("../src/structure.rs"); let code_only: String = source .lines() .filter(|l| { let t = l.trim_start(); !t.starts_with("//") && !t.starts_with("*") }) .collect::>() .join("\n"); for forbidden in ["is_pdf_ua", "is_conformant", "ComplianceReport"] { assert!( !code_only.contains(forbidden), "structure.rs gained `{forbidden}` in code; mechanical checks \ cannot decide PDF/UA conformance (ADR 0011 rule 4)" ); } } #[test] fn every_structure_fixture_parses_without_panicking() { for name in [ "tagged.pdf", "role_map.pdf", "inaccessible.pdf", "cyclic.pdf", "untagged.pdf", "table.pdf", "marked_content_colour.pdf", ] { let bytes = corpus(&format!("structure/{name}")); let mut doc = PdfDocument::parse(&bytes) .unwrap_or_else(|e| panic!("structure/{name} should parse: {e}")); let tree = doc.structure_tree(); if let Ok(t) = tree { let _ = t.findings(); let _ = t.reading_order(); let _ = t.headings(); } let _ = doc.page(0); } }