fix(a11y): auto-tagging bounds the content it plans across all pages (#6090)

This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-09 18:20:34 +08:00
1 parent 14ae96a2ac
commit 3a900b8bd2
2 files changed
+27

No files matched your search

+10
View File
@@ -9,6 +9,10 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
const MAX_OPERATORS: usize = 100_000;
/// What all pages together may decode and keep: pages can share one stream, so the per-page limits
/// alone let a small file retain a plan for every page. Real documents stay far under these.
const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024;
const MAX_TOTAL_OPERATORS: usize = 2_000_000;
const MAX_TEXT_OBJECTS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -186,6 +190,7 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
}
let mut out = Vec::with_capacity(pages.len());
let mut total = 0usize;
let (mut total_bytes, mut total_ops) = (0usize, 0usize);
for (index, page) in pages.iter().enumerate() {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
@@ -197,10 +202,15 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let bytes = content_bytes(doc, page, index)?;
total_bytes = total_bytes.saturating_add(bytes.len());
let parsed = parse(&bytes);
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
total_ops = total_ops.saturating_add(parsed.ops.len());
if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS {
return Err(unsupported(index, "the document has too much content to auto-tag in one edit"));
}
let spans = text_spans(&parsed.ops, index)?;
total = total.saturating_add(spans.len());
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
+17
View File
@@ -1763,3 +1763,20 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
}
#[test]
fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() {
// Each page is just under the per-page limit; sharing one stream makes the total 21 times it.
let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990));
let pages = 21;
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect();
let mut bodies = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")),
stream(&content),
];
bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string()));
let mut doc = pdf(&bodies, "");
let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") };
assert!(reason.contains("too much content"), "{reason}");
}