From 3a900b8bd21de13f9de841796b452f716103eb3d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:20:34 -0500 Subject: [PATCH] fix(a11y): auto-tagging bounds the content it plans across all pages (#6090) --- crates/a11y/src/autotag.rs | 10 ++++++++++ crates/a11y/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 5c0dc2bd19..1b898fdfda 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -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, 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, 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 }); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 66ae06fce4..e5bc3e425a 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -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 = (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}"); +}