mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
fix(a11y): heading titles decode their source strings within a shared allowance (#6049)
This commit is contained in:
1 parent
9642ed6f87
commit
74e1e40e67
2 files changed
+47
-4
No files matched your search
@@ -37,9 +37,16 @@ struct Walker<'a> {
|
||||
visits: usize,
|
||||
/// Per page: the text each MCID shows (read once per page).
|
||||
texts: HashMap<usize, HashMap<i64, String>>,
|
||||
/// What is left of [`MAX_TITLE_SOURCE_BYTES`].
|
||||
title_left: usize,
|
||||
out: Vec<Heading>,
|
||||
}
|
||||
|
||||
/// Bytes of `/ActualText`, `/Alt` and `/T` strings the headings of one document decode in all.
|
||||
/// Titles are cut to [`MAX_TITLE`] characters, but many headings can name one large string, and
|
||||
/// each would otherwise be decoded in full first.
|
||||
const MAX_TITLE_SOURCE_BYTES: usize = 32 << 20;
|
||||
|
||||
/// The headings, in document order. Headings without text or a page are left out.
|
||||
pub fn headings(doc: &Document) -> Vec<Heading> {
|
||||
let Some(root) = tree_root(doc) else { return Vec::new() };
|
||||
@@ -52,6 +59,7 @@ pub fn headings(doc: &Document) -> Vec<Heading> {
|
||||
seen: HashSet::new(),
|
||||
visits: 0,
|
||||
texts: HashMap::new(),
|
||||
title_left: MAX_TITLE_SOURCE_BYTES,
|
||||
out: Vec::new(),
|
||||
};
|
||||
if let Some(k) = root.get(b"K") {
|
||||
@@ -131,17 +139,34 @@ impl Walker<'_> {
|
||||
self.marks(kids, pg, depth + 1, &mut marks);
|
||||
}
|
||||
let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return };
|
||||
let doc = self.doc;
|
||||
let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty());
|
||||
let title = match attr(b"ActualText") {
|
||||
let title = match self.attr(d, b"ActualText") {
|
||||
Some(t) => Some(t),
|
||||
None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")),
|
||||
None => match self.marked_text(&marks, page) {
|
||||
Some(t) => Some(t),
|
||||
None => self.attr(d, b"Alt").or_else(|| self.attr(d, b"T")),
|
||||
},
|
||||
};
|
||||
let Some(title) = title else { return };
|
||||
let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE).collect();
|
||||
self.out.push(Heading { obj, level, title, page });
|
||||
}
|
||||
|
||||
/// The non-blank text of the string `key` holds, charged against the title allowance by the
|
||||
/// longer of its raw and decoded size (a string that does not fit is not decoded).
|
||||
fn attr(&mut self, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
let o = self.doc.resolve(d.get(key)?);
|
||||
let s = o.as_string()?;
|
||||
// A text string decodes to at most three bytes per raw byte, so one whose worst case does
|
||||
// not fit the remaining allowance is never decoded: the raw check alone let a string at
|
||||
// the raw limit decode to three times it before the allowance was reduced.
|
||||
if s.bytes.len().saturating_mul(3) > self.title_left {
|
||||
return None;
|
||||
}
|
||||
let text = s.to_text();
|
||||
self.title_left = self.title_left.saturating_sub(text.len().max(s.bytes.len()));
|
||||
Some(text).filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The text the marked content shows (`page`: where marks without a page of their own are).
|
||||
fn marked_text(&mut self, marks: &[(Option<ObjRef>, i64)], page: usize) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
@@ -1559,6 +1559,24 @@ fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_naming_one_large_string_stop_decoding_it_at_the_allowance() {
|
||||
let count = 100;
|
||||
let kids = (0..count).map(|i| format!("{} 0 R", 8 + i)).collect::<Vec<_>>().join(" ");
|
||||
let mut objects = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(),
|
||||
stream("BT ET"),
|
||||
"<< /Type /StructTreeRoot /K 6 0 R >>".to_string(),
|
||||
format!("<< /S /Document /P 5 0 R /K [{kids}] >>"),
|
||||
format!("({})", "a".repeat(512 << 10)),
|
||||
];
|
||||
objects.extend((0..count).map(|_| "<< /S /H1 /P 6 0 R /Pg 3 0 R /ActualText 7 0 R >>".to_string()));
|
||||
let found = headings(&pdf(&objects, ""));
|
||||
assert!(!found.is_empty() && found.len() < count, "{}", found.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_has_no_headings() {
|
||||
let mut objs = headings_doc();
|
||||
|
||||
Reference in new issue
Block a user