mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 22:02:33 +00:00
* 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:
9 files changed
+472
-26
No files matched your search
+50
-16
@@ -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 })
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user