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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-12 17:58:31 +01:00
1 parent 490287fc9d
commit 120c62fb82
9 files changed
+472 -26

No files matched your search

+50 -16
View File
@@ -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<Op>,
spans: Vec<(usize, usize)>,
evidence: Vec<Evidence>,
artifacts: HashMap<usize, usize>,
}
@@ -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<Plan, AutoTagError> {
fn plan_page(
doc: &Document,
page: &pdfcraft_model::Page,
index: usize,
totals: &mut (usize, usize),
fonts: &mut autotag_roles::Fonts,
) -> Result<Plan, AutoTagError> {
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<Vec<Plan>, AutoTagError> {
@@ -268,8 +289,9 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, 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<Vec<Plan>, AutoTagError> {
Ok(out)
}
fn mark_ops(ops: Vec<Op>, starts: &HashMap<usize, usize>, ends: &HashSet<usize>, artifacts: &HashMap<usize, usize>) -> Result<Vec<Op>, AutoTagError> {
fn mark_ops(
ops: Vec<Op>,
starts: &HashMap<usize, (usize, &'static str)>,
ends: &HashSet<usize>,
artifacts: &HashMap<usize, usize>,
) -> Result<Vec<Op>, AutoTagError> {
let artifact_ends: HashSet<usize> = 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<AutoTagResult, AutoTagError> {
let plans = plan(doc)?;
let all: Vec<Evidence> = 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<AutoTagResult, AutoTagError> {
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<AutoTagResult, AutoTagError> {
}
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 })
}
+382
View File
@@ -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<i64, (usize, usize)> = 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<Rc<Metrics>>,
}
/// 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<ObjRef, Option<Rc<Metrics>>>,
measured: usize,
}
impl Fonts {
fn get(&mut self, doc: &Document, font: &Object) -> Option<Rc<Metrics>> {
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<i64, (usize, usize)>,
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<Evidence> {
let mut by_name: HashMap<Vec<u8>, Option<Rc<Metrics>>> = 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<Evidence> = (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<Evidence> {
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<usize> { 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");
}
}
+1
View File
@@ -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;
+9 -5
View File
@@ -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 <</MCID 0>> BDC", "/P <</MCID 0>> 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 <</MCID 0>> BDC"), "{text}");
assert!(text.contains(first), "{text}");
assert!(text.contains("EMC"), "{text}");
}
let kinds: Vec<String> = 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);
+1 -1
View File
@@ -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))
}
+25
View File
@@ -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<u8> {
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<u8> {
let objs: Vec<String> = 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(),
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.<br>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<br>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<br>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.<br>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<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>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.<br>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<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>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<br>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 |
+2 -2
View File
@@ -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"