From 33ea9ac3d2741eda3819cb8e1668798e53855656 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 21:21:38 +0000 Subject: [PATCH] fix(a11y): share one allowance for the text figure and tag listings decode; read a shared Alt once --- crates/a11y/src/alt.rs | 39 +++++++++++++++++++++++++++++++++++- crates/a11y/src/structure.rs | 17 ++++++++++++++-- crates/a11y/src/tags.rs | 19 +++++++++--------- crates/a11y/src/tests.rs | 15 ++++++++++++++ 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index fde1048e47..19dcbe7913 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -306,6 +306,42 @@ pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> out } +/// Bytes of document strings one listing (figures, tags, a tag's content) decodes and keeps in all. +/// Many elements can name one large indirect string, and each would otherwise get a copy of it. +pub(crate) const MAX_LISTED_TEXT: usize = 32 << 20; + +/// What is left of [`MAX_LISTED_TEXT`]. +pub(crate) struct TextBudget(usize); + +impl TextBudget { + pub(crate) fn new() -> Self { + TextBudget(MAX_LISTED_TEXT) + } + + /// Spend `n` bytes: `false`, spending what is left, when they don't fit. + pub(crate) fn charge(&mut self, n: usize) -> bool { + let fits = n <= self.0; + self.0 = self.0.saturating_sub(n); + fits + } + + /// The text of the string `key` holds, charged by the longer of its raw and decoded sizes + /// (nothing is decoded once the raw bytes alone don't fit). `None` past the allowance. + pub(crate) fn text(&mut self, doc: &Document, d: &Dict, key: &[u8]) -> Option { + let o = doc.resolve(d.get(key)?); + self.string(o.as_string()?) + } + + /// [`Self::text`] for a string already in hand. + pub(crate) fn string(&mut self, s: &PdfString) -> Option { + if s.bytes.len() > self.0 { + return None; + } + let text = s.to_text(); + self.charge(text.len().max(s.bytes.len())).then_some(text) + } +} + /// The figures, in document order. pub fn figures(doc: &Document) -> Vec
{ let Some(root) = tree_root(doc) else { return Vec::new() }; @@ -328,10 +364,11 @@ pub fn figures(doc: &Document) -> Vec
{ } let bounds: HashMap> = wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect(); + let mut text = TextBudget::new(); figures .into_iter() .map(|(e, page)| { - let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); + let alt = text.text(doc, &e.dict, b"Alt"); let on_page = page.and_then(|p| bounds.get(&p)); let bbox = on_page.and_then(|b| mcids(doc, &e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } diff --git a/crates/a11y/src/structure.rs b/crates/a11y/src/structure.rs index b5d5c1f9ab..300e0a5916 100644 --- a/crates/a11y/src/structure.rs +++ b/crates/a11y/src/structure.rs @@ -220,7 +220,8 @@ fn walk(doc: &Document, pages: &[Page], v: &mut V) { let Some(root) = struct_root(doc) else { return }; let role_map = root.as_dict().and_then(|d| d.get(b"RoleMap")).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let page_index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - let mut w = Walker { doc, role_map, page_index, seen: HashSet::new(), frames: Vec::new(), tasks: Vec::new(), opened: 0 }; + let mut w = + Walker { doc, role_map, page_index, seen: HashSet::new(), frames: Vec::new(), tasks: Vec::new(), opened: 0, text_of: Default::default() }; if root.as_dict().is_some_and(|d| d.contains(b"K")) { w.tasks.push(Task::Node { loc: Loc { owner: root, path: vec![Step::K] }, depth: 0, page: None }); } @@ -286,6 +287,9 @@ struct Walker<'a> { tasks: Vec, /// Elements opened so far: the next element's position in document order. opened: usize, + /// Whether an indirect string holds any text, by reference: many elements can name one large + /// string, which is decoded once instead of once per element. + text_of: std::cell::RefCell>, } impl Walker<'_> { @@ -407,7 +411,16 @@ impl Walker<'_> { /// Whether `key` holds a string with some text in it. fn has_text(&self, d: &Dict, key: &[u8]) -> bool { - d.get(key).map(|o| self.doc.resolve(o)).and_then(|o| o.as_string().map(|s| !s.to_text().trim().is_empty())).unwrap_or(false) + let Some(value) = d.get(key) else { return false }; + let reference = value.as_ref(); + if let Some(known) = reference.and_then(|r| self.text_of.borrow().get(&r).copied()) { + return known; + } + let any = self.doc.resolve(value).as_string().is_some_and(|s| !s.to_text().trim().is_empty()); + if let Some(r) = reference { + self.text_of.borrow_mut().insert(r, any); + } + any } /// The standard type of `ty`, following the role map (a bounded number of steps). diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index ba9d639018..fe6e0befdf 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -147,14 +147,11 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T Ok(()) } -fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option { - d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text)) -} - /// List reachable, indirect tags in structure-tree order. pub fn tags(doc: &Document) -> Vec { let pages = pdfcraft_model::pages(doc); let page_index: std::collections::HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let mut budget = alt::TextBudget::new(); alt::elements(doc) .into_iter() .filter_map(|e| { @@ -163,11 +160,11 @@ pub fn tags(doc: &Document) -> Vec { parent: e.parent, depth: e.depth, kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(), - title: text(doc, &e.dict, b"T"), - actual_text: text(doc, &e.dict, b"ActualText"), - alt: text(doc, &e.dict, b"Alt"), - language: text(doc, &e.dict, b"Lang"), - summary: table_attribute(doc, &e.dict, b"Summary").and_then(|o| o.as_string().map(PdfString::to_text)), + title: budget.text(doc, &e.dict, b"T"), + actual_text: budget.text(doc, &e.dict, b"ActualText"), + alt: budget.text(doc, &e.dict, b"Alt"), + language: budget.text(doc, &e.dict, b"Lang"), + summary: table_attribute(doc, &e.dict, b"Summary").and_then(|o| o.as_string().and_then(|s| budget.string(s))), scope: table_attribute(doc, &e.dict, b"Scope").and_then(|o| o.as_name().map(|n| String::from_utf8_lossy(n).into_owned())), page: e.page.and_then(|p| page_index.get(&p).copied()), }) @@ -186,6 +183,8 @@ pub fn tag_content(doc: &Document, object: ObjRef) -> Result } // Each page's content is parsed once, however many of its MCIDs the tag owns. let mut texts: std::collections::HashMap> = std::collections::HashMap::new(); + // A mark listed twice (or a long run of text listed many times) copies its text each time. + let mut budget = alt::TextBudget::new(); Ok(marks .into_iter() .map(|(page, mcid)| { @@ -198,7 +197,7 @@ pub fn tag_content(doc: &Document, object: ObjRef) -> Result e.insert(pages.get(i).map(|p| crate::headings::mcid_texts(doc, &p.dict)).unwrap_or_default()) } }; - page_texts.get(&mcid).cloned() + page_texts.get(&mcid).filter(|t| budget.charge(t.len())).cloned() }) .unwrap_or_default(); TagContentItem { page, mcid, text } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 3024319ec3..4c72d52f51 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -335,6 +335,21 @@ fn structure_doc(objects: &[String]) -> Document { pdf(&objs, "") } +#[test] +fn many_tags_naming_one_large_string_share_the_listing_text_allowance() { + let elements = 100; + let kids = (0..elements).map(|i| format!("{} 0 R", 5 + i)).collect::>().join(" "); + let mut objects = vec![format!("<< /S /Document /K [{kids}] >>"), format!("({})", "a".repeat(512 << 10))]; + objects.extend((0..elements).map(|_| "<< /S /Figure /Alt 4 0 R >>".to_string())); + let doc = structure_doc(&objects); + let listed = tags(&doc); + assert_eq!(listed.len(), elements + 1); + let kept: usize = listed.iter().filter_map(|t| t.alt.as_ref()).map(String::len).sum(); + assert!(kept > 0 && kept <= crate::alt::MAX_LISTED_TEXT, "{kept}"); + let kept: usize = figures(&doc).iter().filter_map(|f| f.alt.as_ref()).map(String::len).sum(); + assert!(kept > 0 && kept <= crate::alt::MAX_LISTED_TEXT, "{kept}"); +} + #[test] fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() { let mut doc =