mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 01:41:18 +00:00
fix(a11y): share one allowance for the text figure and tag listings decode; read a shared Alt once
This commit is contained in:
1 parent
ecdd1e658c
commit
33ea9ac3d2
4 files changed
+77
-13
No files matched your search
+38
-1
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user