M12: preview text structure proposals for auto-tagging (#1383)

* M12: preview text structure proposals for auto-tagging

* Auto-tag preview: no unregistered command, allowed on copy-restricted files like a11y.check

The preview is read-only with no UI command yet, like render_preview, so it no
longer names the Planned a11y.autotag catalog id (tool_table_is_well_formed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M12: preview text structure proposals for auto-tagging

* Keep read-only auto-tag preview separate from unavailable command

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 08:20:13 -04:00
1 parent e33b66e9b5
commit 2839152af1
19 files changed
+184 -249

No files matched your search

+1
View File
@@ -140,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
- **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged.
- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged.
+23
View File
@@ -203,6 +203,29 @@ fn review_html(record: &Value) -> Result<String> {
}
impl Automation {
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?;
let candidates: Vec<Value> = preview
.candidates
.into_iter()
.map(|c| {
json!({
"type": c.kind, "reason": c.reason, "text": c.text,
"text_truncated": c.text_truncated, "bbox_user_space": c.rect,
"font_size": c.size, "bold": c.bold, "needs_review": true,
})
})
.collect();
Ok(json!({
"page": page + 1, "candidates": candidates,
"unclassified_images": preview.unclassified_images,
"unclassified_text_blocks": preview.unclassified_text_blocks,
"existing_structure": preview.existing_structure,
"note": "Text roles are proposals only. Review images, tables, lists, annotations and existing tags before applying structure.",
}))
}
fn a11y_options(&self, a: &Args) -> Result<Options> {
let mut o = Options::default();
if a.opt_bool("all")?.unwrap_or(false) {
+1
View File
@@ -716,6 +716,7 @@ impl Automation {
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
"accessibility_check" => self.a11y_check(&a)?,
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
"ocr_recognize" => self.ocr_recognize(&a)?,
"js_run" => self.js_run(&a)?,
"js_document_scripts" => self.js_document_scripts(&a)?,
+3
View File
@@ -862,6 +862,9 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "folder"],
)),
t("accessibility_autotag_preview", "Preview automatic tags", "Propose paragraph and heading tags for visible text blocks on one page. Returns page bounds, tentative roles and reasons. Does not change the PDF or claim accessibility compliance. Images, tables, lists, annotations and existing structure require separate review.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
.ro()
.cmd("a11y.check")
+30
View File
@@ -115,6 +115,36 @@ fn auto(dir: &Path) -> Automation {
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
}
#[test]
fn autotag_preview_proposes_text_without_changing_the_pdf() {
let dir = workdir("autotag-preview");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let before = std::fs::read(dir.join("a.pdf")).unwrap();
let preview = ok(&mut a, "accessibility_autotag_preview", json!({ "doc": doc, "page": 1 }));
assert_eq!(preview["page"], 1);
assert_eq!(preview["candidates"][0]["type"], "P");
assert_eq!(preview["candidates"][0]["text"], "Page 1");
assert_eq!(preview["candidates"][0]["needs_review"], true);
assert_eq!(preview["unclassified_images"], 0);
assert_eq!(preview["unclassified_text_blocks"], 0);
assert_eq!(preview["existing_structure"], false);
assert_eq!(std::fs::read(dir.join("a.pdf")).unwrap(), before);
assert!(matches!(
a.call("accessibility_autotag_preview", &json!({ "doc": doc, "page": 4 })),
Err(ToolError::InvalidArgs(_) | ToolError::Failed(_))
));
let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({ "doc": blank, "page": 1, "text": "Annual report", "at": [20, 25], "size": 24 }));
ok(&mut a, "page_add_text", json!({ "doc": blank, "page": 1, "text": "First paragraph", "at": [20, 100], "size": 12 }));
ok(&mut a, "page_add_text", json!({ "doc": blank, "page": 1, "text": "Second paragraph", "at": [20, 140], "size": 12 }));
let preview = ok(&mut a, "accessibility_autotag_preview", json!({ "doc": blank, "page": 1 }));
assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "Annual report" && c["type"] == "H1"));
assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P"));
}
/// A one-page PDF whose form holds one text field named `name`.
fn form_pdf(name: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
-15
View File
@@ -1824,21 +1824,6 @@ fn a_line_drawn_twice_is_replaced_everywhere() {
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
}
#[test]
fn a_paragraph_dragged_across_another_leaves_it_alone() {
// #1368: each drag is its own edit. Dropping Alpha on Bravo, then dragging it on, treated
// Bravo (now under Alpha) as a copy of Alpha and deleted it.
let mut doc = text_page("BT /F1 12 Tf 100 700 Td (Alpha line) Tj ET BT /F1 12 Tf 100 500 Td (Bravo line) Tj ET");
let down = text::BlockStyle { offset: Some([0.0, -200.0]), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, None, &down).unwrap();
let alpha = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Alpha line").unwrap();
text::rewrite_block(&mut doc, 0, alpha, None, &down).unwrap();
let doc = reopen(&doc);
let mut texts: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
texts.sort();
assert_eq!(texts, ["Alpha line", "Bravo line"]);
}
#[test]
fn paragraphs_move_and_rewrap_to_a_new_width() {
let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET";
+15 -16
View File
@@ -1472,7 +1472,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
// Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
// line drawn in the same area go too, so the replacement is all that shows.
let drops = coincident_ops(&lines, &[&target]);
let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect));
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
let page_ref = p.obj;
@@ -1493,21 +1493,19 @@ fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
}
/// The operators of every copy of one of `members`. Documents sometimes
/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes
/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would
/// show the old text under the replaced line. Copies show the same text and overlap a member's
/// rect by more than half of the smaller rect; neighbouring lines share no area, and other text
/// that merely overlaps (a paragraph dragged across another, #1368) is not a copy.
fn coincident_ops(lines: &[TextLine], members: &[&TextLine]) -> std::collections::HashSet<usize> {
/// show the old text under the replaced line. Copies overlap a member's rect by more than half of
/// the smaller rect; neighbouring lines share no area.
fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet<usize> {
let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0);
let mut drop = std::collections::HashSet::new();
for l in lines {
let covered = members.iter().any(|m| {
let r = m.rect;
let ix = (r[2].min(l.rect[2]) - r[0].max(l.rect[0])).max(0.0);
let iy = (r[3].min(l.rect[3]) - r[1].max(l.rect[1])).max(0.0);
let small = area(r).min(area(l.rect));
small > 0.0 && ix * iy / small > 0.5 && l.text.trim() == m.text.trim()
let covered = rects.iter().any(|m| {
let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0);
let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0);
let small = area(*m).min(area(l.rect));
small > 0.0 && ix * iy / small > 0.5
});
if covered {
drop.extend(l.ops.iter().copied());
@@ -1529,9 +1527,9 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?;
let members: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).collect();
let mut drops = coincident_ops(&lines, &members);
drops.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
let rects: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).collect();
let mut drops = coincident_ops(&lines, &rects);
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
@@ -1925,7 +1923,8 @@ pub(crate) fn rewrite_block_with_limit(
block_ops.push(Op::new("Q", vec![]));
// The paragraph's operators go, and so does anything drawn in the same area (a fake-bold
// second copy, an invisible text layer): the new text is all that may show.
let mut drop = coincident_ops(&lines, &members);
let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect();
let mut drop = coincident_ops(&lines, &member_rects);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
// Text shown earlier in the same text object stays first: the paragraph then goes where it
// was, between two halves of the text object, so it keeps its place in reading order.
+2 -1
View File
@@ -696,7 +696,8 @@ pub(super) fn rewrite_styled_block(
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
let mut drop = coincident_ops(lines, &members);
let rects: Vec<_> = members.iter().map(|l| l.rect).collect();
let mut drop = coincident_ops(lines, &rects);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
let o = &first.origin;
let start = first.ops.first().copied().unwrap_or(o.bt_op);
+86
View File
@@ -0,0 +1,86 @@
//! Reviewable structure proposals for pages that have not yet been tagged.
//!
//! A proposal is deliberately not a PDF tag. It records the visible text blocks and the
//! evidence for a tentative role, so a later edit can attach marked content and a parent tree
//! without presenting a font-size guess as a finished accessible document.
use crate::Document;
/// A bounded text block proposed for a structure element.
#[derive(Clone, Debug, PartialEq)]
pub struct Candidate {
pub kind: &'static str,
pub reason: &'static str,
pub text: String,
pub text_truncated: bool,
pub rect: [f64; 4],
pub size: f64,
pub bold: bool,
}
/// The visible text portion of one page's proposed structure.
#[derive(Clone, Debug, PartialEq)]
pub struct Preview {
pub candidates: Vec<Candidate>,
/// Raster or grouped artwork requires separate figure/artifact review.
pub unclassified_images: usize,
/// Text blocks omitted because their geometry or font size was invalid.
pub unclassified_text_blocks: usize,
/// Existing structure must be reconciled before any proposal is applied.
pub existing_structure: bool,
}
const MAX_BLOCKS: usize = 2_048;
const MAX_TEXT_CHARS: usize = 512;
impl Document {
/// Propose text tags for one 0-based page. This reads the current working PDF and does not
/// alter it. Every result needs review before tags are written; image and table semantics
/// are outside this text-only proposal.
pub fn autotag_preview(&self, page: usize) -> Result<Preview, String> {
if self.info.pages.get(page).is_none() {
return Err(format!("page {} is outside the document", page.saturating_add(1)));
}
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let existing_structure = editor.cos.root().is_some_and(|r| editor.cos.get(r).as_dict().is_some_and(|d| d.contains(b"StructTreeRoot")));
let blocks = pdfcraft_edit::text_blocks(&editor.cos, page).map_err(|e| e.to_string())?;
if blocks.len() > MAX_BLOCKS {
return Err(format!("page {} has too many text blocks to preview (maximum {MAX_BLOCKS})", page.saturating_add(1)));
}
let mut sizes: Vec<f64> = blocks.iter().map(|b| b.size).filter(|s| s.is_finite() && *s > 0.0).collect();
sizes.sort_by(f64::total_cmp);
let body_size = sizes.get(sizes.len().saturating_sub(1) / 2).copied().unwrap_or(12.0);
let mut unclassified_text_blocks = 0;
let candidates = blocks
.into_iter()
.filter(|b| {
let valid = !b.text.trim().is_empty() && b.rect.iter().all(|v| v.is_finite()) && b.size.is_finite() && b.size > 0.0;
if !valid {
unclassified_text_blocks += 1;
}
valid
})
.map(|b| {
let mut chars = b.text.chars();
let text: String = chars.by_ref().take(MAX_TEXT_CHARS).collect();
let text_truncated = chars.next().is_some();
let short_line = b.rows.len() == 1 && b.text.chars().count() <= 120;
let heading = short_line && b.size.is_finite() && b.size > body_size * 1.35;
Candidate {
kind: if heading { "H1" } else { "P" },
reason: if heading {
"short, single-line text larger than the page body"
} else {
"visible text block; semantic role needs review"
},
text,
text_truncated,
rect: b.rect,
size: b.size,
bold: b.bold,
}
})
.collect();
Ok(Preview { candidates, unclassified_images: self.page_images(page).len(), unclassified_text_blocks, existing_structure })
}
}
+1
View File
@@ -13,6 +13,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod actions;
pub mod autotag;
pub mod camera;
pub mod camera_clean;
pub mod camera_edges;
+1
View File
@@ -819,6 +819,7 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() {
assert!(!enabled(&s, id, c), "{c} is disabled");
}
assert!(enabled(&s, id, "edit.find") && enabled(&s, id, "a11y.check"), "searching and accessibility don't copy");
assert!(s.get(id).unwrap().autotag_preview(0).is_ok(), "like a11y.check, the auto-tag preview is allowed");
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(s.get(oid).unwrap().allows_copying());
assert!(extracting.iter().all(|c| enabled(&s, oid, c)));
-20
View File
@@ -762,10 +762,6 @@ impl Metrics {
let cid = self.cid(code);
return self.cid_widths.contains_key(&cid) || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) || !self.subset;
}
// Simple fonts show one-byte codes; a larger one (from /Differences) would be written as its last byte.
if code > 0xFF {
return false;
}
match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
Some(w) => *w > 0.0 || code == 32,
None => self.widths.is_empty() && !self.subset,
@@ -1224,22 +1220,6 @@ mod tests {
assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..]));
}
/// A `/Differences` code past 255 can't be written in one byte, so it encodes nothing
/// instead of its last byte (300 would come out as 44, a comma).
#[test]
fn differences_codes_past_one_byte_are_not_encoded() {
let euro_at = |code: i64| {
let mut doc = Document::new_empty();
let mut enc = Dict::new();
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(code), Object::name("Euro")]));
let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]);
Metrics::from_dict(&doc, &f)
};
let m = euro_at(200);
assert_eq!((m.encode("€").as_deref(), m.decode(b"\xc8").as_str()), (Some(&b"\xc8"[..]), "€"));
assert_eq!(euro_at(300).encode("€"), None);
}
#[test]
fn subset_fonts_only_encode_the_glyphs_they_have() {
let mut doc = Document::new_empty();
+4 -6
View File
@@ -430,14 +430,12 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
out
}
fn values_of(doc: &Document, o: Option<&Object>, kind: FieldKind) -> Vec<String> {
fn values_of(doc: &Document, o: Option<&Object>) -> Vec<String> {
let Some(o) = o else { return Vec::new() };
// "Off" is a button's off state; in a text or choice field it is the value.
let button = matches!(kind, FieldKind::CheckBox | FieldKind::Radio);
match &*doc.resolve(o) {
Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(),
Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(),
other => text_of(other).filter(|s| !(button && s == "Off") && !s.is_empty()).into_iter().collect(),
other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(),
}
}
@@ -820,8 +818,8 @@ fn walk(
name: inh.name.clone(),
obj: r,
kind,
value: values_of(doc, inh.v.as_ref(), kind),
default: values_of(doc, inh.dv.as_ref(), kind),
value: values_of(doc, inh.v.as_ref()),
default: values_of(doc, inh.dv.as_ref()),
flags: ff,
max_len: inh.max_len,
options,
-20
View File
@@ -1865,23 +1865,3 @@ fn invalid_appearance_patches_are_refused_before_anything_is_written() {
assert!(set_props(&mut doc, "name", &props).is_err());
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
/// "Off" is a button's off state, not a value; a text or choice field that holds "Off" reads
/// it (#1320), while an unchecked check box still reads as no value.
#[test]
fn a_literal_off_value_reads_as_off_outside_buttons() {
for (kind, value) in [
(NewField::Text { multiline: false }, FieldValue::Text("Off".into())),
(NewField::Combo { options: vec!["On".into(), "Off".into()], editable: false }, FieldValue::Choice(vec!["Off".into()])),
(NewField::List { options: vec!["On".into(), "Off".into()], multi: false }, FieldValue::Choice(vec!["Off".into()])),
] {
let mut doc = one_page();
add_field(&mut doc, 0, [50.0, 600.0, 250.0, 620.0], &kind, Some("power")).unwrap();
set_value(&mut doc, "power", &value).unwrap();
assert_eq!(field(&fields(&reopen(&doc)), "power").value, ["Off"], "{kind:?}");
}
let mut doc = one_page();
add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::CheckBox, Some("power")).unwrap();
set_value(&mut doc, "power", &FieldValue::Check(false)).unwrap();
assert!(field(&fields(&doc), "power").value.is_empty());
}
+3 -5
View File
@@ -48,16 +48,14 @@ impl PageRange {
}
pub(crate) fn ui(&mut self, ui: &mut egui::Ui, count: usize) {
// Only all-pages mode supplies defaults; 1..=1 is a valid custom range.
if self.all {
self.from = 1;
self.to = count.max(1);
}
ui.horizontal(|ui| {
widgets::radio_value(ui, &mut self.all, true, tl!("All pages"));
widgets::radio_value(ui, &mut self.all, false, tl!("Pages from"));
ui.add_enabled(!self.all, egui::DragValue::new(&mut self.from).range(1..=count.max(1)));
ui.label(tl!("to"));
if self.to == 1 && self.from == 1 {
self.to = count.max(1);
}
ui.add_enabled(!self.all, egui::DragValue::new(&mut self.to).range(1..=count.max(1)));
ui.label(tl!("Subset"));
let label = match self.subset {
-155
View File
@@ -1,155 +0,0 @@
//! Shared export/marks page ranges retain the user's selection across dialog frames (#1304).
use egui_kittest::{Harness, kittest::Queryable};
use pdfcraft_ui_egui::{
PdfCraftApp,
marks::{PageRange, Subset},
};
fn fixture() -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R 7 0 R 10 0 R] /Count 3 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
];
for (i, name) in ["First", "Second", "Third"].iter().enumerate() {
let page = 4 + i * 3;
objects.push(
format!(
"<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Im {} 0 R >> >> >>",
page + 1,
page + 2
)
.into_bytes(),
);
let content = format!("BT /F1 12 Tf 20 150 Td ({name} page) Tj ET q 20 0 0 20 20 20 cm /Im Do Q");
objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes());
let color = [b"ff0000>".as_slice(), b"00ff00>", b"0000ff>"][i];
let mut image = b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >>\nstream\n".to_vec();
image.extend_from_slice(color);
image.extend_from_slice(b"\nendstream");
objects.push(image);
}
let mut bytes = b"%PDF-1.7\n".to_vec();
for (i, object) in objects.iter().enumerate() {
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
bytes.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
bytes
}
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.run_inline = true;
app.open_bytes("doc.pdf", None, fixture()).unwrap();
app
});
h.run_steps(4);
h
}
#[test]
fn explicit_ranges_survive_redraws_in_every_shared_dialog() {
for command in ["export.image", "export.text", "export.all_images", "edit.header_footer", "edit.watermark", "edit.background", "edit.bates"] {
let mut h = harness();
assert!(h.state_mut().execute(command));
h.run_steps(2);
let export = command.starts_with("export.");
let range = if export { h.state().export_draft.range } else { h.state().marks_draft.range };
assert!(range.all);
assert_eq!((range.from, range.to), (1, 3));
h.get_by_label("Pages from").click();
h.run_steps(2);
for (from, to, subset, expected) in
[(1, 1, Subset::All, vec![0]), (2, 3, Subset::All, vec![1, 2]), (1, 2, Subset::Odd, vec![0]), (1, 3, Subset::Even, vec![1])]
{
let requested = PageRange { all: false, from, to, subset };
if export {
h.state_mut().export_draft.range = requested;
} else {
h.state_mut().marks_draft.range = requested;
}
h.run_steps(3);
let actual = if export { h.state().export_draft.range } else { h.state().marks_draft.range };
assert_eq!(actual, requested, "{command}");
assert_eq!(actual.pages(3), expected, "{command}");
}
h.get_by_label("All pages").click();
h.run_steps(2);
let range = if export { &mut h.state_mut().export_draft.range } else { &mut h.state_mut().marks_draft.range };
range.subset = Subset::All;
assert_eq!(range.pages(3), [0, 1, 2]);
}
}
#[test]
fn page_one_exports_exclude_other_pages_text_and_images() {
for command in ["export.image", "export.text", "export.all_images"] {
let mut h = harness();
let dir = std::env::temp_dir().join(format!("pdfcraft-page-range-{command}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
assert!(h.state_mut().execute(command));
h.run_steps(2);
h.state_mut().export_draft.range = PageRange { all: false, from: 1, to: 1, subset: Subset::All };
h.run_steps(3);
h.get_by_label("Export").click();
h.run_steps(4);
let files: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|f| f.unwrap().path()).collect();
assert_eq!(files.len(), 1, "{command}: {files:?}");
if command == "export.text" {
assert_eq!(std::fs::read_to_string(&files[0]).unwrap().trim(), "First page");
} else if command == "export.image" {
assert_eq!(files[0].file_name().unwrap(), "doc_page_1.png");
} else {
let image = image::open(&files[0]).unwrap().to_rgb8();
assert_eq!(image.get_pixel(0, 0).0, [255, 0, 0]);
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
fn pixels(h: &Harness<'static, PdfCraftApp>) -> Vec<Vec<u8>> {
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
let source = doc.export_source();
let mut renderer = pdfcraft_render::PageRenderer::new(source.bytes, source.config);
(0..3)
.map(|page| {
let shot = renderer.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() });
assert!(shot.error.is_none(), "{:?}", shot.error);
shot.rgba.to_vec()
})
.collect()
}
#[test]
fn adding_and_updating_marks_changes_only_page_one() {
for command in ["edit.header_footer", "edit.watermark", "edit.background"] {
let mut h = harness();
let original = pixels(&h);
for update in [false, true] {
let before = pixels(&h);
let action = if update { format!("{command}.update") } else { command.to_string() };
assert!(h.state_mut().execute(&action));
h.run_steps(2);
let draft = &mut h.state_mut().marks_draft;
draft.range = PageRange { all: false, from: 1, to: 1, subset: Subset::All };
let text = if update { "UPDATED" } else { "DRAFT" };
draft.hf.text[4] = text.into();
draft.wm.text = text.into();
draft.bg.color = if update { [0.0, 1.0, 0.0] } else { [1.0, 0.0, 0.0] };
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(4);
assert!(h.state().dialog.is_none());
let after = pixels(&h);
assert!(after[0] != before[0], "{action}: page one must change");
assert!(after[1..] == original[1..], "{action}: other pages must remain unchanged");
}
}
}
+8 -6
View File
@@ -182,14 +182,16 @@ update the numbers.
- **Impact:** government and legal users sign with tokens and need B-LT/B-LTA.
- **Estimate:** 40–70 h; tokens need hardware to test. **Doc:** [pdf-spec-parity.md](pdf-spec-parity.md).
### 11. Accessibility: checker only, no tagging tools
### 11. Accessibility: no automatic structure creation
- **Missing:** autotag (document and fields), Tags, Order and Content panels, tag properties,
Reading Order tool, artifact marking, table editor, role map, Make Accessible action, Read Out
Loud; full keyboard operation of PdfCraft itself and a screen-reader audit.
- **Evidence:** all 32 checker rules shipped, but 21 tagging and reading features planned;
- **Missing:** apply auto-tag proposals to marked content and a valid parent tree, autotag fields,
visual Tags, Order and Content panels, Reading Order tool, table editor, Make Accessible action,
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 text-only auto-tag preview exist. The preview does not write PDF structure;
`a11y.keyboard-only` (P0) planned, `a11y.app-screen-reader` partial.
- **Impact:** remediation work is impossible; PdfCraft itself is not yet usable without a mouse.
- **Impact:** untagged files still need another editor to build their structure; PdfCraft itself is
not yet usable without a mouse.
- **Estimate:** 40–70 h. **Doc:** [target-app-parity.md](target-app-parity.md).
### 12. No measured fidelity harness against Acrobat
+2 -1
View File
@@ -95,7 +95,7 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| 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). 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.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| 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.<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. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| 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 | 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, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. 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). 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. Missing: a right-to-left layout, a Chinese UI face in releases, 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 |
@@ -143,6 +143,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M12: bounded text-only auto-tag preview through the engine and automation; actual PDF structure writing and visual review remain open. Estimates unchanged. |
| 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. |
| 2026-10-12 | minor | M3: case-insensitive Find matches normal, final and capital Greek sigma; exact search, word boundaries, glyph ranges and match limits have regression coverage. The tab-menu test uses Finder on macOS. Estimates unchanged. |
| 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. Estimates unchanged. |
+4 -4
View File
@@ -8825,11 +8825,11 @@ title = "Automatically tag a document"
area = "M"
tier = "P1"
milestone = "M12"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["accessibility_autotag_preview"]
evidence = ["crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf"]
notes = "Preview proposes paragraph or heading roles for bounded visible text blocks on one page and flags them for review. It does not write tags, mark content, build a parent tree or classify images, tables, lists and annotations."
[[feature]]
id = "a11y.autotag-fields"