fix(a11y): share one allowance for the text figure and tag listings decode; read a shared Alt once

This commit is contained in:
Dylan M. Taylor committed 2026-11-03 21:21:38 +00:00
1 parent ecdd1e658c
commit 33ea9ac3d2
4 files changed
+77 -13

No files matched your search

+38 -1
View File
@@ -306,6 +306,42 @@ pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) ->
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<String> {
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<String> {
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<Figure> {
let Some(root) = tree_root(doc) else { return Vec::new() };
@@ -328,10 +364,11 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
}
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
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 }
+15 -2
View File
@@ -220,7 +220,8 @@ fn walk<V: Visitor>(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<ObjRef, usize> = 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<Task>,
/// 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<HashMap<ObjRef, bool>>,
}
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).
+9 -10
View File
@@ -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<String> {
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<TagInfo> {
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = 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<TagInfo> {
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<Vec<TagContentItem>
}
// Each page's content is parsed once, however many of its MCIDs the tag owns.
let mut texts: std::collections::HashMap<usize, std::collections::HashMap<i64, String>> = 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<Vec<TagContentItem>
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 }
+15
View File
@@ -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::<Vec<_>>().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 =