From 120c62fb82f41998ecdf6dbbfe39f27d4c9a410e Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Thu, 12 Nov 2026 11:58:31 -0500 Subject: [PATCH] Auto-tag Apply tags headings by size and weight (#7062) (#7069) * feat(a11y): auto-tag Apply tags headings by size and weight Apply now chooses H1-H6 or P per text object instead of P for all. The body style is the half-point size showing the most characters (bold when most of those are bold). A short object (at most 3 lines, 200 characters, with a letter) is a heading when it is 1.15x the body, or bold at 0.9x the body over a regular body in at most 2 lines. Heading styles rank across the document, larger and bolder first, capped at H6, and a level never skips past the one before it so heading-nesting passes. Size is Tf x Tm x CTM with q/Q tracked; bold is Metrics.bold. Text in render mode 3 (an OCR layer) is not counted. Font metrics are measured once per font object per document, at most 256 fonts. AutoTagResult gains a headings count. Parity, gaps, roadmap and the apply tool's note say headings are tagged. Fixes #7062. Co-Authored-By: Claude Opus 5.5 * fix(a11y): rank heading styles by map and skip clip-only text Look up each heading style's rank in a map built once after sorting, so ranking is linear in the number of text objects. Text in render mode 7 (clip only) paints nothing, so it is excluded from heading evidence like mode 3. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: KenM76 Co-authored-by: Claude Opus 5.5 --- crates/a11y/src/autotag.rs | 66 +++- crates/a11y/src/autotag_roles.rs | 382 +++++++++++++++++++++++ crates/a11y/src/lib.rs | 1 + crates/a11y/src/tests.rs | 14 +- crates/automation/src/a11y.rs | 2 +- crates/automation/tests/it/automation.rs | 25 ++ docs/gaps.md | 2 +- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 +- 9 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 crates/a11y/src/autotag_roles.rs diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 59d30930ff..bf3df8d0a4 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -1,7 +1,8 @@ //! Create a valid, reviewable structure tree for untagged PDFs. //! -//! Each text object becomes a paragraph, which can then be retagged with the existing -//! tag-property tool after human review. Painted paths, shadings and images outside text objects +//! Each text object becomes a paragraph, or a heading when its size or weight sets it apart from +//! the body text ([`crate::autotag_roles`]); either can be retagged with the existing tag-property +//! tool after human review. Painted paths, shadings and images outside text objects //! are wrapped in `/Artifact BMC … EMC` so the resulting page passes the tagged-content check. use std::collections::{HashMap, HashSet}; @@ -9,6 +10,8 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Op, Pieces, serialize_ops}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use crate::autotag_roles::{self, Evidence}; + const MAX_PAGES: usize = 1_000; const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; const MAX_OPERATORS: usize = 100_000; @@ -22,6 +25,7 @@ const MAX_TEXT_OBJECTS: usize = 4_096; pub struct AutoTagResult { pub pages: usize, pub paragraphs: usize, + pub headings: usize, } #[derive(Debug, thiserror::Error, PartialEq)] @@ -44,6 +48,7 @@ struct Plan { page: ObjRef, ops: Vec, spans: Vec<(usize, usize)>, + evidence: Vec, artifacts: HashMap, } @@ -98,6 +103,14 @@ fn page_xobjects(doc: &Document, page: &Dict) -> Dict { .unwrap_or_default() } +fn page_fonts(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|r| r.get(b"Font").map(|x| doc.resolve(x)))) + .and_then(|x| x.as_dict().cloned()) + .unwrap_or_default() +} + fn harmless_outside(op: &[u8]) -> bool { matches!( op, @@ -183,7 +196,13 @@ fn harmless_inside(op: &[u8]) -> bool { /// `totals` is the decoded bytes and operators of the pages planned so far, bounded by /// `MAX_TOTAL_BYTES` and `MAX_TOTAL_OPERATORS`. -fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: &mut (usize, usize)) -> Result { +fn plan_page( + doc: &Document, + page: &pdfcraft_model::Page, + index: usize, + totals: &mut (usize, usize), + fonts: &mut autotag_roles::Fonts, +) -> Result { if page.dict.contains(b"StructParents") { return Err(unsupported(index, "page already has a structure parent key")); } @@ -248,7 +267,9 @@ fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: if path.is_some() { return Err(unsupported(index, "unterminated path object")); } - Ok(Plan { page: page.obj, ops: parsed.ops, spans, artifacts }) + let resources = page_fonts(doc, &page.dict); + let evidence = autotag_roles::evidence(doc, fonts, &resources, &parsed.ops, &spans); + Ok(Plan { page: page.obj, ops: parsed.ops, spans, evidence, artifacts }) } fn plan(doc: &Document) -> Result, AutoTagError> { @@ -268,8 +289,9 @@ fn plan(doc: &Document) -> Result, AutoTagError> { let mut out = Vec::with_capacity(pages.len()); let mut total = 0usize; let mut totals = (0usize, 0usize); + let mut fonts = autotag_roles::Fonts::default(); for (index, page) in pages.iter().enumerate() { - let planned = plan_page(doc, page, index, &mut totals)?; + let planned = plan_page(doc, page, index, &mut totals, &mut fonts)?; total = total.saturating_add(planned.spans.len()); out.push(planned); } @@ -279,16 +301,21 @@ fn plan(doc: &Document) -> Result, AutoTagError> { Ok(out) } -fn mark_ops(ops: Vec, starts: &HashMap, ends: &HashSet, artifacts: &HashMap) -> Result, AutoTagError> { +fn mark_ops( + ops: Vec, + starts: &HashMap, + ends: &HashSet, + artifacts: &HashMap, +) -> Result, AutoTagError> { let artifact_ends: HashSet = artifacts.values().copied().collect(); let extra = starts.len().saturating_add(artifacts.len()).saturating_mul(2); let mut marked = Vec::with_capacity(ops.len().saturating_add(extra)); for (at, op) in ops.into_iter().enumerate() { let is_artifact = artifacts.contains_key(&at); - if let Some(mcid) = starts.get(&at) { + if let Some(&(mcid, role)) = starts.get(&at) { let mut properties = Dict::new(); - properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?)); - marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)])); + properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); + marked.push(Op::new("BDC", vec![Object::name(role), Object::Dict(properties)])); } else if is_artifact { marked.push(Op::new("BMC", vec![Object::name("Artifact")])); } @@ -334,16 +361,18 @@ fn write_structure( Ok(()) } -/// Tag every text object in an untagged PDF as a paragraph and mark painted paths, shadings and +/// Tag every text object in an untagged PDF as a paragraph or a heading and mark painted paths, shadings and /// images outside text objects as artifacts. The full structure tree, parent tree and /// marked-content IDs are written together. Unsupported operators are refused before any -/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles. +/// mutation. On success the caller can use tag-property edits to correct the roles after review. pub fn autotag_text(doc: &mut Document) -> Result { let plans = plan(doc)?; + let all: Vec = plans.iter().flat_map(|p| p.evidence.iter().copied()).collect(); + let mut roles = autotag_roles::roles(&all).into_iter(); let mut next = doc.clone(); let catalog = next.root().ok_or(AutoTagError::NoCatalog)?; let (root, document) = (next.add(Object::Null), next.add(Object::Null)); - let (mut children, mut numbers, mut tagged_pages, mut paragraphs) = (Vec::new(), Vec::new(), 0usize, 0usize); + let (mut children, mut numbers, mut tagged_pages, mut paragraphs, mut headings) = (Vec::new(), Vec::new(), 0usize, 0usize, 0usize); for page in plans { if page.spans.is_empty() && page.artifacts.is_empty() { continue; @@ -353,18 +382,23 @@ pub fn autotag_text(doc: &mut Document) -> Result { let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?; let mut parents = Vec::with_capacity(page.spans.len()); for (mcid, (first, last)) in page.spans.iter().enumerate() { + let role = roles.next().unwrap_or("P"); let mut element = Dict::new(); element.set(b"Type".to_vec(), Object::name("StructElem")); - element.set(b"S".to_vec(), Object::name("P")); + element.set(b"S".to_vec(), Object::name(role)); element.set(b"P".to_vec(), Object::Ref(document)); element.set(b"Pg".to_vec(), Object::Ref(page.page)); element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); let reference = next.add(element); parents.push(Object::Ref(reference)); children.push(Object::Ref(reference)); - starts.insert(*first, mcid); + starts.insert(*first, (mcid, role)); ends.insert(*last); - paragraphs = paragraphs.saturating_add(1); + if role == "P" { + paragraphs = paragraphs.saturating_add(1); + } else { + headings = headings.saturating_add(1); + } } next.update_dict(page.page, |d| d.set(b"StructParents".to_vec(), Object::Int(key)))?; numbers.extend([Object::Int(key), Object::Array(parents)]); @@ -376,5 +410,5 @@ pub fn autotag_text(doc: &mut Document) -> Result { } write_structure(&mut next, catalog, root, document, children, numbers, tagged_pages)?; *doc = next; - Ok(AutoTagResult { pages: tagged_pages, paragraphs }) + Ok(AutoTagResult { pages: tagged_pages, paragraphs, headings }) } diff --git a/crates/a11y/src/autotag_roles.rs b/crates/a11y/src/autotag_roles.rs new file mode 100644 index 0000000000..a72e14a417 --- /dev/null +++ b/crates/a11y/src/autotag_roles.rs @@ -0,0 +1,382 @@ +//! Heading roles for auto-tagged text objects, from the size and weight of the text each shows. +//! +//! The body style is the size (to the half point) carrying the most characters in the document, +//! and whether most of those characters are bold. A text object is a heading when it shows at most +//! three lines and 200 characters, including a letter, and is either 15% larger than the body or, +//! where the body is not bold, bold at no less than 90% of the body size on at most two lines. +//! Levels rank the heading styles (size, then bold) over the whole document, largest first, capped +//! at 6, and never skip a level after the heading before (the `heading-nesting` check). + +use std::collections::HashMap; +use std::rc::Rc; + +use pdfcraft_content::{Matrix, Op}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_fonts::pdf::Metrics; + +/// A line this much larger than body text reads as a heading. +const HEADING_SIZE_RATIO: f64 = 1.15; +/// Bold text this much of the body size or more can be a heading by weight. +const WEIGHT_SIZE_RATIO: f64 = 0.9; +const MAX_LINES: usize = 3; +const MAX_WEIGHT_LINES: usize = 2; +const MAX_CHARS: usize = 200; +/// Saved graphics states followed; deeper `q` nesting keeps the current state. +const MAX_SAVED: usize = 64; +/// Distinct fonts measured per document; text in any further font counts as unstyled. +const MAX_FONTS: usize = 256; + +/// What one text object shows. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Evidence { + /// The size, in user space, carrying the most of the object's characters. + pub size: f64, + /// Most of the object's characters are in a bold font. + pub bold: bool, + pub lines: usize, + /// Characters shown, whitespace excluded. + pub chars: usize, + pub letters: bool, +} + +/// The structure type for each text object, in document order: `H1`–`H6` or `P`. +pub(crate) fn roles(objects: &[Evidence]) -> Vec<&'static str> { + const HEADINGS: [&str; 6] = ["H1", "H2", "H3", "H4", "H5", "H6"]; + let Some((body, body_bold)) = body_style(objects) else { return vec!["P"; objects.len()] }; + let candidate = |e: &Evidence| { + let fits = e.lines >= 1 && e.lines <= MAX_LINES && e.chars <= MAX_CHARS && e.letters; + let by_size = e.size >= body * HEADING_SIZE_RATIO; + let by_weight = e.bold && !body_bold && e.lines <= MAX_WEIGHT_LINES && e.size >= body * WEIGHT_SIZE_RATIO; + fits && (by_size || by_weight) + }; + let mut styles: Vec<(i64, bool)> = objects.iter().filter(|e| candidate(e)).map(|e| (half_points(e.size), e.bold)).collect(); + styles.sort_by(|a, b| b.cmp(a)); + styles.dedup(); + let ranks: HashMap<(i64, bool), usize> = styles.into_iter().enumerate().map(|(rank, style)| (style, rank)).collect(); + let mut previous = 0usize; + objects + .iter() + .map(|e| { + if !candidate(e) { + return "P"; + } + let rank = ranks.get(&(half_points(e.size), e.bold)).copied().unwrap_or(0); + let level = rank.saturating_add(1).min(previous.saturating_add(1)).min(HEADINGS.len()); + previous = level; + HEADINGS.get(level.saturating_sub(1)).copied().unwrap_or("P") + }) + .collect() +} + +fn half_points(size: f64) -> i64 { + // Sizes are finite and bounded by `evidence`; `as` saturates in any case. + (size * 2.0).round() as i64 +} + +/// The size carrying the most characters, and whether most of them are bold. +fn body_style(objects: &[Evidence]) -> Option<(f64, bool)> { + let mut by_size: HashMap = HashMap::new(); + for e in objects.iter().filter(|e| e.chars > 0) { + let entry = by_size.entry(half_points(e.size)).or_default(); + entry.0 = entry.0.saturating_add(e.chars); + if e.bold { + entry.1 = entry.1.saturating_add(e.chars); + } + } + let (size, (chars, bold)) = by_size.into_iter().filter(|&(s, _)| s > 0).max_by_key(|&(s, (n, _))| (n, s))?; + Some((size as f64 / 2.0, bold.saturating_mul(2) > chars)) +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + tm: Matrix, + tlm: Matrix, + size: f64, + leading: f64, + /// Text render mode 3 or 7: shown text that paints nothing, such as an OCR layer or a + /// clip-only object. + invisible: bool, + metrics: Option>, +} + +/// Font metrics for a whole document, measured once per font object and capped at +/// [`MAX_FONTS`]. +#[derive(Default)] +pub(crate) struct Fonts { + by_ref: HashMap>>, + measured: usize, +} + +impl Fonts { + fn get(&mut self, doc: &Document, font: &Object) -> Option> { + let key = match font { + Object::Ref(r) => Some(*r), + _ => None, + }; + if let Some(found) = key.and_then(|r| self.by_ref.get(&r)) { + return found.clone(); + } + if self.measured >= MAX_FONTS { + return None; + } + self.measured = self.measured.saturating_add(1); + let metrics = doc.resolve(font).as_dict().map(|d| Rc::new(Metrics::from_dict(doc, d))); + if let Some(r) = key { + self.by_ref.insert(r, metrics.clone()); + } + metrics + } +} + +#[derive(Default)] +struct Shown { + by_size: HashMap, + lines: usize, + shown: bool, + moved: bool, + letters: bool, +} + +impl Shown { + fn show(&mut self, state: &State, bytes: &[u8]) { + let Some(metrics) = state.metrics.as_ref().filter(|_| !state.invisible) else { return }; + let text = metrics.decode(bytes); + let chars = text.chars().filter(|c| !c.is_whitespace()).count(); + if chars == 0 { + return; + } + self.letters |= text.chars().any(char::is_alphabetic); + if !self.shown { + (self.shown, self.lines) = (true, 1); + } else if self.moved { + self.lines = self.lines.saturating_add(1); + } + self.moved = false; + let [_, _, c, d, _, _] = state.tm.then(&state.ctm).0; + let size = (state.size * c.hypot(d)).abs(); + if !size.is_finite() || size <= 0.0 || size > 1e6 { + return; + } + let entry = self.by_size.entry(half_points(size)).or_default(); + entry.0 = entry.0.saturating_add(chars); + if metrics.bold { + entry.1 = entry.1.saturating_add(chars); + } + } + + /// A line move counts once text is shown after it, so blank lines and trailing moves do not. + fn new_line(&mut self) { + self.moved = self.shown; + } + + fn finish(self) -> Evidence { + let chars = self.by_size.values().fold(0usize, |n, v| n.saturating_add(v.0)); + let Some((size, (count, bold))) = self.by_size.into_iter().max_by_key(|&(s, (n, _))| (n, s)) else { + return Evidence::default(); + }; + Evidence { size: size as f64 / 2.0, bold: bold.saturating_mul(2) > count, lines: self.lines, chars, letters: self.letters } + } +} + +/// The evidence for each `(BT, ET)` operator span of a page, in order. `resources` is the page's +/// `/Font` resource dictionary; `fonts` is shared by the document's pages. +pub(crate) fn evidence(doc: &Document, fonts: &mut Fonts, resources: &Dict, ops: &[Op], spans: &[(usize, usize)]) -> Vec { + let mut by_name: HashMap, Option>> = HashMap::new(); + let mut state = + State { ctm: Matrix::IDENTITY, tm: Matrix::IDENTITY, tlm: Matrix::IDENTITY, size: 0.0, leading: 0.0, invisible: false, metrics: None }; + let (mut saved, mut unsaved) = (Vec::new(), 0usize); + let mut out = Vec::with_capacity(spans.len()); + let mut spans = spans.iter().peekable(); + let mut current: Option<(usize, Shown)> = None; + for (at, op) in ops.iter().enumerate() { + if current.is_none() && spans.peek().is_some_and(|s| s.0 == at) { + current = spans.next().map(|s| (s.1, Shown::default())); + } + let object = current.as_mut().map(|c| &mut c.1); + match op.op.as_slice() { + b"q" if saved.len() < MAX_SAVED => saved.push(state.clone()), + b"q" => unsaved = unsaved.saturating_add(1), + b"Q" if unsaved > 0 => unsaved -= 1, + b"Q" => { + if let Some(s) = saved.pop() { + state = s; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + state.ctm = Matrix(m).then(&state.ctm); + } + } + b"BT" => (state.tm, state.tlm) = (Matrix::IDENTITY, Matrix::IDENTITY), + b"Tf" => { + state.size = op.num(1).unwrap_or(state.size); + state.metrics = op + .name(0) + .and_then(|name| by_name.entry(name.to_vec()).or_insert_with(|| resources.get(name).and_then(|f| fonts.get(doc, f))).clone()); + } + b"TL" => state.leading = op.num(0).unwrap_or(state.leading), + b"Tr" => state.invisible = matches!(op.num(0), Some(m) if m == 3.0 || m == 7.0), + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.op == b"TD" { + state.leading = -y; + } + state.tlm = Matrix::translate(x, y).then(&state.tlm); + state.tm = state.tlm; + if y != 0.0 + && let Some(o) = object + { + o.new_line(); + } + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + let moved = m[5] != state.tlm.0[5]; + (state.tm, state.tlm) = (Matrix(m), Matrix(m)); + if moved && let Some(o) = object { + o.new_line(); + } + } + } + b"T*" | b"'" | b"\"" => { + state.tlm = Matrix::translate(0.0, -state.leading).then(&state.tlm); + state.tm = state.tlm; + if let Some(o) = object { + o.new_line(); + if op.op != b"T*" + && let Some(Object::String(s)) = op.operands.last() + { + o.show(&state, &s.bytes); + } + } + } + b"Tj" => { + if let (Some(o), Some(Object::String(s))) = (object, op.operands.first()) { + o.show(&state, &s.bytes); + } + } + b"TJ" => { + if let (Some(o), Some(Object::Array(items))) = (object, op.operands.first()) { + for item in items { + if let Object::String(s) = item { + o.show(&state, &s.bytes); + } + } + } + } + _ => {} + } + if current.as_ref().is_some_and(|c| c.0 == at) + && let Some((_, o)) = current.take() + { + out.push(o.finish()); + } + } + out.resize(spans.len().saturating_add(out.len()), Evidence::default()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(size: f64, chars: usize) -> Evidence { + Evidence { size, bold: false, lines: 1, chars, letters: true } + } + + fn bold(e: Evidence) -> Evidence { + Evidence { bold: true, ..e } + } + + #[test] + fn a_line_larger_than_the_body_is_a_heading_and_body_text_is_not() { + let objects = [text(20.0, 10), text(12.0, 400), text(12.0, 300)]; + assert_eq!(roles(&objects), ["H1", "P", "P"]); + } + + #[test] + fn the_body_is_the_size_with_the_most_characters_not_the_most_objects() { + // Ten short 9 pt captions and three 12 pt body lines: the body lines hold more characters, + // so 12 pt is the body. Counting objects would make 9 pt the body and the body lines headings. + let mut objects = vec![text(12.0, 150); 3]; + objects.extend(std::iter::repeat_n(text(9.0, 5), 10)); + assert!(roles(&objects).iter().all(|r| *r == "P")); + } + + #[test] + fn bold_at_body_size_is_a_heading_only_where_the_body_is_not_bold() { + let plain_body = [bold(text(12.0, 15)), text(12.0, 500)]; + assert_eq!(roles(&plain_body), ["H1", "P"]); + let bold_body = [bold(text(12.0, 15)), bold(text(12.0, 500))]; + assert_eq!(roles(&bold_body), ["P", "P"]); + } + + #[test] + fn long_text_numbers_alone_and_four_lines_stay_paragraphs() { + let long = text(20.0, MAX_CHARS + 1); + let number = Evidence { letters: false, ..text(20.0, 2) }; + let four_lines = Evidence { lines: 4, ..text(20.0, 40) }; + let two_bold_lines = Evidence { lines: 3, ..bold(text(12.0, 40)) }; + assert_eq!(roles(&[long, number, four_lines, two_bold_lines, text(12.0, 2_000)]), ["P"; 5]); + } + + #[test] + fn levels_rank_styles_largest_first_and_never_skip_a_level() { + // Styles: 24 pt, 18 pt, bold 12 pt. The first heading met is 18 pt, ranked second, which + // would skip H1: it becomes H1, and the bold heading after it H2, not H3. + let objects = [text(18.0, 10), bold(text(12.0, 10)), text(24.0, 10), text(18.0, 10), bold(text(12.0, 10)), text(12.0, 3_000)]; + assert_eq!(roles(&objects), ["H1", "H2", "H1", "H2", "H3", "P"]); + } + + #[test] + fn levels_stop_at_six() { + let mut objects: Vec = (0..8).map(|i| text(40.0 - f64::from(i) * 2.0, 10)).collect(); + objects.push(text(12.0, 5_000)); + assert_eq!(roles(&objects), ["H1", "H2", "H3", "H4", "H5", "H6", "H6", "H6", "P"]); + } + + #[test] + fn a_document_without_text_has_only_paragraphs() { + assert_eq!(roles(&[Evidence::default(), Evidence::default()]), ["P", "P"]); + assert!(roles(&[]).is_empty()); + } + + /// The evidence for each `BT`..`ET` of `content`, drawn in Helvetica-Bold as `/F1`. + fn walk(content: &[u8]) -> Vec { + let doc = Document::new_empty(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold")); + let mut resources = Dict::new(); + resources.set(b"F1".to_vec(), Object::Dict(font)); + let ops = pdfcraft_content::parse(content).ops; + let at = |name: &[u8]| -> Vec { ops.iter().enumerate().filter(|(_, o)| o.op == name).map(|(i, _)| i).collect() }; + let spans: Vec<(usize, usize)> = at(b"BT").into_iter().zip(at(b"ET")).collect(); + evidence(&doc, &mut Fonts::default(), &resources, &ops, &spans) + } + + #[test] + fn size_comes_from_the_font_size_the_text_matrix_and_the_ctm() { + let found = walk( + b"q 2 0 0 2 0 0 cm BT /F1 1 Tf 9 0 0 9 10 10 Tm (Title) Tj ET Q BT /F1 12 Tf 10 50 Td (one) Tj T* (two) ' 0 -14 Td (three) Tj ET BT ET", + ); + assert_eq!(found[0], Evidence { size: 18.0, bold: true, lines: 1, chars: 5, letters: true }); + assert_eq!(found[1], Evidence { size: 12.0, bold: true, lines: 3, chars: 11, letters: true }); + assert_eq!(found[2], Evidence::default()); + assert_eq!(found.len(), 3); + } + + #[test] + fn line_moves_leading_invisible_text_and_absurd_sizes() { + let found = walk( + b"BT /F1 10 Tf 1 0 0 1 0 90 Tm (a) Tj 1 0 0 1 40 90 Tm (b) Tj 1 0 0 1 0 70 Tm (c) Tj ET BT /F1 10 Tf 14 TL 0 0 Td (a) Tj 0 0 (b) \" 0 -12 TD (c) Tj T* (d) Tj ET BT /F1 10 Tf 3 Tr (hidden) Tj 7 Tr (clip) Tj 0 Tr (shown) Tj ET BT /F1 2000000 Tf (huge) Tj ET", + ); + assert_eq!(found[0].lines, 2, "a Tm on the same baseline is not a new line"); + assert_eq!(found[1].lines, 4, "\" and TD each start a line, and T* uses TD's leading"); + assert_eq!(found[2].chars, 5, "render mode 3 and 7 text is not counted"); + assert_eq!(found[3], Evidence::default(), "a size past 1e6 is dropped"); + } +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 50e632dec8..d3492f50e7 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -15,6 +15,7 @@ use pdfcraft_cos::Document; mod alt; mod autotag; mod autotag_fields; +mod autotag_roles; mod content; mod headings; mod layout_table; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 890eb33e9f..4a981aaf5b 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -50,7 +50,7 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() { "", ); let result = autotag_text(&mut doc).unwrap(); - assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 3 }); + assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 2, headings: 1 }); let catalog = doc.get(doc.root().unwrap()); let root = catalog.as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); let tree = doc.get(root); @@ -62,14 +62,18 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() { assert_eq!(entries[2].as_int(), Some(1)); assert_eq!(entries[1].as_array().unwrap().len(), 2); assert_eq!(entries[3].as_array().unwrap().len(), 1); - for page in pdfcraft_model::pages(&doc) { + // The 18 pt line stands out from the 12 pt body: a heading, the rest paragraphs. + let first_marks = ["/H1 <> BDC", "/P <> BDC"]; + for (page, first) in pdfcraft_model::pages(&doc).iter().zip(first_marks) { let content = page.dict.get(b"Contents").unwrap(); let stream = doc.resolve(content); let Object::Stream(stream) = &*stream else { panic!("marked page content") }; let text = String::from_utf8(stream.decoded_strict().unwrap()).unwrap(); - assert!(text.contains("/P <> BDC"), "{text}"); + assert!(text.contains(first), "{text}"); assert!(text.contains("EMC"), "{text}"); } + let kinds: Vec = tags(&doc).iter().map(|t| t.kind.clone()).collect(); + assert_eq!(kinds, ["Document", "H1", "P", "P"]); let report = check(&doc, &Options::default()); assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); assert_eq!(status(&report, Rule::TaggedContent), Status::Passed); @@ -104,7 +108,7 @@ fn autotag_text_reads_content_with_parameters_given_by_reference() { let mut predicted = pdfcraft_cos::Stream::flate(Dict::new(), &rows); predicted.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); doc.set(ObjRef::new(4, 0), predicted); - assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1 }); + assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1, headings: 0 }); let page = &pdfcraft_model::pages(&doc)[0]; let written = doc.resolve(page.dict.get(b"Contents").unwrap()); let Object::Stream(written) = &*written else { panic!("page content") }; @@ -157,7 +161,7 @@ fn autotag_text_marks_vector_graphics_and_images_as_artifacts() { "", ); let result = autotag_text(&mut doc).unwrap(); - assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1 }); + assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1, headings: 0 }); let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None }); assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); assert_eq!(status(&report, Rule::TaggedContent), Status::Passed); diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index b17192aab0..cab0fb9a61 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -217,7 +217,7 @@ impl Automation { let tags = self.accessibility_tags(a)?; let out = json!({ "tags": tags["tags"], "count": tags["count"], - "note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.", + "note": "Text objects were tagged as headings (H1-H6, by size and weight against the body text) or paragraphs. Review heading roles, reading order and remaining accessibility findings manually.", }); Ok(with_warnings(out, warnings)) } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f749ed2953..5251bf96ce 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -509,6 +509,25 @@ fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() { assert!(pages.iter().all(|page| page.dict.contains(b"StructParents"))); } +/// #7062: Apply tags short text larger than the body as headings, ranked across the pages, and +/// the body text as paragraphs. +#[test] +fn autotag_apply_tags_headings_by_size_across_pages() { + let dir = workdir("autotag-headings"); + let body = "BT /FR 11 Tf 72 670 Td (Body text that runs long enough to be the most common style) Tj ET"; + let page1 = format!("BT /FB 18 Tf 72 700 Td (Introduction) Tj ET {body} BT /FB 14 Tf 72 640 Td (Background) Tj ET"); + let page2 = format!("BT /FB 18 Tf 72 700 Td (Results) Tj ET {body}"); + std::fs::write(dir.join("headed.pdf"), bold_and_regular_pdf(&page1, &page2)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "headed.pdf" }))["doc"].as_u64().unwrap(); + let applied = ok(&mut a, "accessibility_autotag_apply", json!({ "doc": doc })); + let types: Vec<&str> = applied["tags"].as_array().unwrap().iter().map(|t| t["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["Document", "H1", "P", "H2", "H1", "P"]); + let check = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["heading-nesting"] })); + let nesting = check["results"].as_array().unwrap().iter().find(|r| r["rule"] == "heading-nesting").unwrap(); + assert_eq!(nesting["status"], "passed", "{check}"); +} + #[test] fn autotag_form_fields_writes_reviewable_widget_tags() { let dir = workdir("autotag-form-fields"); @@ -531,6 +550,12 @@ fn styled_headings_pdf() -> Vec { let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \ BT /FB 14 Tf 72 640 Td (Background) Tj ET"; let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + bold_and_regular_pdf(page1, page2) +} + +/// A two-page PDF whose pages draw `page1` and `page2` with `/FB` (Helvetica-Bold) and `/FR` +/// (Helvetica). +fn bold_and_regular_pdf(page1: &str, page2: &str) -> Vec { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(), diff --git a/docs/gaps.md b/docs/gaps.md index 1a1c04a9da..6f2ab13219 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -250,7 +250,7 @@ update the numbers. Read Out Loud; full keyboard operation of PdfCraft itself and a screen-reader audit. - **Evidence:** all 32 checker rules, figure alt text, tag property and sibling order edits, and a bounded auto-tag preview exist. Apply writes valid structure and parent-tree links for - text-only files; it refuses mixed content and existing tags; + text-only files, tagging headings by size and weight; it refuses mixed content and existing tags; `a11y.keyboard-only` (P0) planned, `a11y.app-screen-reader` partial. - **Impact:** most untagged files still need another editor to build their structure; PdfCraft itself is not yet usable without a mouse. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ca83dedd9..abc6d13b9f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -296,7 +296,7 @@ total matches the parity estimate. | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, heading and paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Local customization: adjustable, floating or side-docked hotbar with optional full-side strip and persistent tool picker; freehand highlighting is grouped with Highlight. Estimates unchanged. Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e533ecf6c7..6eb57527d3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9158,8 +9158,8 @@ milestone = "M12" status = "partial" commands = ["a11y.autotag"] tools = ["accessibility_autotag_preview", "accessibility_autotag_apply"] -evidence = ["crates/automation/tests/it/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/it/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/ui-egui/tests/it/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"] -notes = "Auto-tag writes real marked content, a Document structure element, P tags and a parent tree for text-only PDFs, with undo and Tags-panel review. It refuses mixed content, forms, annotations and existing tags. The preview separately proposes headings. Images, tables, lists, links, fields and headings are not classified or tagged by Apply." +evidence = ["crates/automation/tests/it/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/it/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/automation/tests/it/automation.rs::autotag_apply_tags_headings_by_size_across_pages", "crates/a11y/src/autotag_roles.rs::levels_rank_styles_largest_first_and_never_skip_a_level", "crates/ui-egui/tests/it/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"] +notes = "Auto-tag writes real marked content, a Document structure element, H1-H6 and P tags and a parent tree for text-only PDFs, with undo and Tags-panel review. A text object is a heading when it is short and larger or bolder than the body text; levels rank those styles across the document and never skip a level. It refuses mixed content, forms, annotations and existing tags. The preview still proposes headings by its own single-line rule, so it can differ from Apply. Running headers and footers can still become headings (#7065); invisible text (render mode 3) is ignored. Images, tables, lists, links and fields are not classified or tagged by Apply." [[feature]] id = "a11y.autotag-fields"