mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-15 03:37:03 -04:00
Add reviewable text-only PDF auto-tagging (#1440)
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
0372243ef2
commit
890b83c4c4
18 files changed
+433
-12
No files matched your search
Generated
+1
@@ -3587,6 +3587,7 @@ version = "0.7.1"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -142,6 +142,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged.
|
||||
- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
|
||||
- **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.
|
||||
|
||||
@@ -14,5 +14,6 @@ workspace = true
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-filters = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Create a valid, reviewable structure tree for PDFs whose painted content is text only.
|
||||
//!
|
||||
//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph,
|
||||
//! which can then be retagged with the existing tag-property tool after human review.
|
||||
|
||||
use pdfcraft_content::{Op, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
const MAX_PAGES: usize = 1_000;
|
||||
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_OPERATORS: usize = 100_000;
|
||||
const MAX_TEXT_OBJECTS: usize = 4_096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct AutoTagResult {
|
||||
pub pages: usize,
|
||||
pub paragraphs: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum AutoTagError {
|
||||
#[error("the document already has a structure tree; review its existing tags instead")]
|
||||
AlreadyTagged,
|
||||
#[error("the document has no text objects to tag")]
|
||||
NoText,
|
||||
#[error("the document has too many pages to auto-tag in one edit (maximum {MAX_PAGES})")]
|
||||
TooManyPages,
|
||||
#[error("page {page}: {reason}")]
|
||||
Unsupported { page: usize, reason: String },
|
||||
#[error("the PDF has no document catalog")]
|
||||
NoCatalog,
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
struct Plan {
|
||||
page: ObjRef,
|
||||
ops: Vec<Op>,
|
||||
spans: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
fn unsupported(page: usize, reason: impl Into<String>) -> AutoTagError {
|
||||
AutoTagError::Unsupported { page: page + 1, reason: reason.into() }
|
||||
}
|
||||
|
||||
fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Vec<u8>, AutoTagError> {
|
||||
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) };
|
||||
let resolved = doc.resolve(contents);
|
||||
let pieces: Vec<Object> = match &*resolved {
|
||||
Object::Array(items) => items.clone(),
|
||||
Object::Stream(_) => vec![contents.clone()],
|
||||
_ => return Err(unsupported(index, "contents are not a stream or array of streams")),
|
||||
};
|
||||
let mut all = Vec::new();
|
||||
for item in pieces {
|
||||
let value = doc.resolve(&item);
|
||||
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
|
||||
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
|
||||
if remaining == 0 {
|
||||
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
|
||||
}
|
||||
let chain = stream.filters();
|
||||
let decoded = if chain.is_empty() {
|
||||
if stream.raw.len() > remaining {
|
||||
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
|
||||
}
|
||||
stream.raw.to_vec()
|
||||
} else {
|
||||
pdfcraft_filters::decode(&chain, &stream.raw, remaining)
|
||||
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
|
||||
};
|
||||
all.extend_from_slice(&decoded);
|
||||
all.push(b'\n');
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
fn harmless_outside(op: &[u8]) -> bool {
|
||||
matches!(
|
||||
op,
|
||||
b"q" | b"Q"
|
||||
| b"cm"
|
||||
| b"w"
|
||||
| b"J"
|
||||
| b"j"
|
||||
| b"M"
|
||||
| b"d"
|
||||
| b"ri"
|
||||
| b"i"
|
||||
| b"gs"
|
||||
| b"G"
|
||||
| b"g"
|
||||
| b"RG"
|
||||
| b"rg"
|
||||
| b"K"
|
||||
| b"k"
|
||||
| b"CS"
|
||||
| b"cs"
|
||||
| b"SC"
|
||||
| b"sc"
|
||||
| b"SCN"
|
||||
| b"scn"
|
||||
)
|
||||
}
|
||||
|
||||
fn harmless_inside(op: &[u8]) -> bool {
|
||||
matches!(
|
||||
op,
|
||||
b"Tf"
|
||||
| b"Tc"
|
||||
| b"Tw"
|
||||
| b"Tz"
|
||||
| b"TL"
|
||||
| b"Tr"
|
||||
| b"Ts"
|
||||
| b"Td"
|
||||
| b"TD"
|
||||
| b"Tm"
|
||||
| b"T*"
|
||||
| b"G"
|
||||
| b"g"
|
||||
| b"RG"
|
||||
| b"rg"
|
||||
| b"K"
|
||||
| b"k"
|
||||
| b"CS"
|
||||
| b"cs"
|
||||
| b"SC"
|
||||
| b"sc"
|
||||
| b"SCN"
|
||||
| b"scn"
|
||||
)
|
||||
}
|
||||
|
||||
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
let root = doc.root().ok_or(AutoTagError::NoCatalog)?;
|
||||
let catalog = doc.get(root);
|
||||
let catalog = catalog.as_dict().ok_or(AutoTagError::NoCatalog)?;
|
||||
if catalog.contains(b"StructTreeRoot") {
|
||||
return Err(AutoTagError::AlreadyTagged);
|
||||
}
|
||||
if catalog.contains(b"AcroForm") {
|
||||
return Err(unsupported(0, "form fields need their own tags; this text-only action cannot tag them"));
|
||||
}
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
if pages.len() > MAX_PAGES {
|
||||
return Err(AutoTagError::TooManyPages);
|
||||
}
|
||||
let mut out = Vec::with_capacity(pages.len());
|
||||
let mut total = 0usize;
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
if page.dict.contains(b"StructParents") {
|
||||
return Err(unsupported(index, "page already has a structure parent key"));
|
||||
}
|
||||
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
|
||||
Object::Array(items) => !items.is_empty(),
|
||||
_ => true,
|
||||
}) {
|
||||
return Err(unsupported(index, "annotations need separate structure tags"));
|
||||
}
|
||||
let bytes = content_bytes(doc, page, index)?;
|
||||
let parsed = parse(&bytes);
|
||||
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
|
||||
return Err(unsupported(index, "content has malformed or too many operators"));
|
||||
}
|
||||
let mut start = None;
|
||||
let mut has_text = false;
|
||||
let mut spans = Vec::new();
|
||||
for (at, op) in parsed.ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"BT" if start.is_none() => {
|
||||
start = Some(at);
|
||||
has_text = false;
|
||||
}
|
||||
b"ET" => {
|
||||
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
|
||||
if has_text {
|
||||
spans.push((first, at));
|
||||
if spans.len() > MAX_TEXT_OBJECTS {
|
||||
return Err(unsupported(index, "too many text objects (maximum 4096)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
|
||||
name if start.is_some() && harmless_inside(name) => {}
|
||||
name if start.is_none() && harmless_outside(name) => {}
|
||||
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
|
||||
}
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(index, "unterminated BT text object"));
|
||||
}
|
||||
total = total.saturating_add(spans.len());
|
||||
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
|
||||
}
|
||||
if total == 0 {
|
||||
return Err(AutoTagError::NoText);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree,
|
||||
/// parent tree and marked-content IDs are written together. Mixed content is refused before any
|
||||
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
|
||||
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
|
||||
let plans = plan(doc)?;
|
||||
let mut next = doc.clone();
|
||||
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
|
||||
let root = next.add(Object::Null);
|
||||
let document = next.add(Object::Null);
|
||||
let mut children = Vec::new();
|
||||
let mut numbers = Vec::new();
|
||||
let mut tagged_pages = 0usize;
|
||||
let mut paragraphs = 0usize;
|
||||
for page in plans {
|
||||
if page.spans.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
|
||||
let mut parents = Vec::with_capacity(page.spans.len());
|
||||
let mut starts = std::collections::HashMap::new();
|
||||
let mut ends = std::collections::HashSet::new();
|
||||
for (mcid, (first, last)) in page.spans.iter().enumerate() {
|
||||
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"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);
|
||||
ends.insert(*last);
|
||||
paragraphs = paragraphs.saturating_add(1);
|
||||
}
|
||||
let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2)));
|
||||
for (at, op) in page.ops.into_iter().enumerate() {
|
||||
if let Some(mcid) = 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)]));
|
||||
}
|
||||
marked.push(op);
|
||||
if ends.contains(&at) {
|
||||
marked.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
}
|
||||
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
|
||||
next.update_dict(page.page, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Ref(stream));
|
||||
d.set(b"StructParents".to_vec(), Object::Int(key));
|
||||
})?;
|
||||
numbers.push(Object::Int(key));
|
||||
numbers.push(Object::Array(parents));
|
||||
tagged_pages += 1;
|
||||
}
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(numbers));
|
||||
let parent_tree = next.add(tree);
|
||||
let mut doc_element = Dict::new();
|
||||
doc_element.set(b"Type".to_vec(), Object::name("StructElem"));
|
||||
doc_element.set(b"S".to_vec(), Object::name("Document"));
|
||||
doc_element.set(b"P".to_vec(), Object::Ref(root));
|
||||
doc_element.set(b"K".to_vec(), Object::Array(children));
|
||||
next.set(document, doc_element);
|
||||
let mut structure = Dict::new();
|
||||
structure.set(b"Type".to_vec(), Object::name("StructTreeRoot"));
|
||||
structure.set(b"K".to_vec(), Object::Ref(document));
|
||||
structure.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
|
||||
structure.set(b"ParentTreeNextKey".to_vec(), Object::Int(i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?));
|
||||
next.set(root, structure);
|
||||
let mark_info = next.get(catalog).as_dict().and_then(|d| d.get(b"MarkInfo")).map(|m| next.resolve(m)).and_then(|m| m.as_dict().cloned());
|
||||
let mut mark_info = mark_info.unwrap_or_default();
|
||||
mark_info.set(b"Marked".to_vec(), Object::Bool(true));
|
||||
next.update_dict(catalog, |d| {
|
||||
d.set(b"StructTreeRoot".to_vec(), Object::Ref(root));
|
||||
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
|
||||
})?;
|
||||
*doc = next;
|
||||
Ok(AutoTagResult { pages: tagged_pages, paragraphs })
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use std::collections::{BTreeSet, HashMap};
|
||||
use pdfcraft_cos::Document;
|
||||
|
||||
mod alt;
|
||||
mod autotag;
|
||||
mod content;
|
||||
mod headings;
|
||||
mod report;
|
||||
@@ -20,6 +21,7 @@ mod structure;
|
||||
mod tags;
|
||||
|
||||
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
|
||||
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
|
||||
pub use headings::{Heading, headings};
|
||||
pub use report::{REPORT_WORDS, report_html};
|
||||
pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -35,6 +35,67 @@ fn failed(r: &Report) -> Vec<Rule> {
|
||||
|
||||
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
|
||||
|
||||
#[test]
|
||||
fn autotag_text_writes_marked_content_and_parent_tree_across_pages() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream("BT /F1 18 Tf 20 160 Td (Heading) Tj ET BT /F1 12 Tf 20 120 Td (Body) Tj ET"),
|
||||
FONT.into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 7 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream("BT /F1 12 Tf 20 120 Td (Second page) Tj ET"),
|
||||
],
|
||||
"",
|
||||
);
|
||||
let result = autotag_text(&mut doc).unwrap();
|
||||
assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 3 });
|
||||
let catalog = doc.get(doc.root().unwrap());
|
||||
let root = catalog.as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
|
||||
let tree = doc.get(root);
|
||||
let parent_tree = tree.as_dict().unwrap().reference(b"ParentTree").unwrap();
|
||||
let numbers = doc.get(parent_tree);
|
||||
let entries = numbers.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_eq!(entries[0].as_int(), Some(0));
|
||||
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) {
|
||||
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("EMC"), "{text}");
|
||||
}
|
||||
let report = check(&doc, &Options::default());
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
|
||||
assert_eq!(status(&report, Rule::TaggedContent), Status::Passed);
|
||||
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(tags(&reopened).len(), 4);
|
||||
assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"),
|
||||
FONT.into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
|
||||
assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
|
||||
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_fails_the_document_rules() {
|
||||
let doc = pdf(
|
||||
|
||||
@@ -203,6 +203,16 @@ fn review_html(record: &Value) -> Result<String> {
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?;
|
||||
let tags = self.accessibility_tags(a)?;
|
||||
Ok(json!({
|
||||
"tags": tags["tags"], "count": tags["count"],
|
||||
"note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.",
|
||||
}))
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -723,6 +723,7 @@ impl Automation {
|
||||
"sign_list" => self.sign_list(&a)?,
|
||||
"accessibility_check" => self.a11y_check(&a)?,
|
||||
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
|
||||
"accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?,
|
||||
"ocr_recognize" => self.ocr_recognize(&a)?,
|
||||
"js_run" => self.js_run(&a)?,
|
||||
"js_document_scripts" => self.js_document_scripts(&a)?,
|
||||
|
||||
@@ -876,6 +876,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
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")
|
||||
|
||||
@@ -145,6 +145,32 @@ fn autotag_preview_proposes_text_without_changing_the_pdf() {
|
||||
assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() {
|
||||
let dir = workdir("autotag-apply");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
let before = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] }));
|
||||
assert_eq!(before["failed"], 2);
|
||||
let rendered_before = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
|
||||
let applied = ok(&mut a, "accessibility_autotag_apply", json!({ "doc": doc }));
|
||||
assert_eq!(applied["count"], 3, "Document plus one paragraph per page");
|
||||
assert_eq!(applied["tags"][1]["type"], "P");
|
||||
let after = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] }));
|
||||
assert_eq!(after["passed"], 2);
|
||||
assert_eq!(a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(), rendered_before);
|
||||
assert!(matches!(a.call("accessibility_autotag_apply", &json!({ "doc": doc })), Err(ToolError::Failed(_))));
|
||||
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "tagged.pdf" }));
|
||||
let saved = std::fs::read(dir.join("tagged.pdf")).unwrap();
|
||||
let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap();
|
||||
let pages = pdfcraft_model::pages(&reopened);
|
||||
assert_eq!(pages.len(), 2);
|
||||
assert!(pages.iter().all(|page| page.dict.contains(b"StructParents")));
|
||||
assert!(reopened.get(reopened.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
|
||||
}
|
||||
|
||||
/// A one-page PDF whose form holds one text field named `name`.
|
||||
fn form_pdf(name: &str) -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
|
||||
@@ -457,7 +457,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
sections: &[ToolSection {
|
||||
title: "Accessibility",
|
||||
items: &[
|
||||
item("Automatically tag PDF", "tag", "a11y.autotag", Planned("M12")),
|
||||
item("Automatically tag PDF", "tag", "a11y.autotag", Ready),
|
||||
item("Change reading options", "book-open", "a11y.reading_options", Ready),
|
||||
item("Check for accessibility", "accessibility", "a11y.check", Ready),
|
||||
item("Open accessibility report", "file-text", "a11y.report", Ready),
|
||||
|
||||
@@ -321,6 +321,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
|
||||
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
|
||||
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
|
||||
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
|
||||
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
|
||||
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
|
||||
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
|
||||
|
||||
@@ -1328,6 +1328,8 @@ pub enum Edit {
|
||||
},
|
||||
/// Document Properties ▸ Initial View (and the language and binding).
|
||||
SetInitialView(Box<InitialView>),
|
||||
/// Build a tagged structure tree for an untagged, text-only document.
|
||||
AutoTagText,
|
||||
/// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is
|
||||
/// the figure element's object number (from `Document::figures`).
|
||||
SetAltText {
|
||||
@@ -1809,6 +1811,7 @@ impl Edit {
|
||||
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
|
||||
Edit::SetInitialView(_) => Label::of("Change initial view"),
|
||||
Edit::AutoTagText => Label::of("Automatically tag text"),
|
||||
Edit::SetAltText { .. } => Label::of("Set alternate text"),
|
||||
Edit::SetTagProperty { .. } => Label::of("Change tag properties"),
|
||||
Edit::MoveTag { .. } => Label::of("Move object"),
|
||||
@@ -1997,6 +2000,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::SetTabOrder { .. }
|
||||
| Edit::MoveInTabOrder { .. }
|
||||
| Edit::SetInitialView(_)
|
||||
| Edit::AutoTagText
|
||||
| Edit::SetAltText { .. }
|
||||
| Edit::SetTagProperty { .. }
|
||||
| Edit::MoveTag { .. }
|
||||
@@ -2271,6 +2275,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
|
||||
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
|
||||
Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?,
|
||||
Edit::AutoTagText => {
|
||||
a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?;
|
||||
}
|
||||
Edit::SetAltText { figure, alt } => {
|
||||
let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
|
||||
a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
|
||||
|
||||
@@ -393,6 +393,11 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
"a11y.report" => self.show_accessibility_report(),
|
||||
"a11y.autotag" => {
|
||||
if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) {
|
||||
self.choose_right_panel(Some(RightPanel::Tags));
|
||||
}
|
||||
}
|
||||
"a11y.alt_text" => self.start_alt_text(),
|
||||
"a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)),
|
||||
"a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)),
|
||||
|
||||
@@ -31,6 +31,25 @@ fn right_click(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_text_tagging_opens_reviewable_tags() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("notes.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
assert!(h.state_mut().execute("a11y.autotag"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().right, Some(RightPanel::Tags));
|
||||
let doc = h.state().session.get(h.state().views[h.state().active.unwrap()].id).unwrap();
|
||||
let tags = doc.tags();
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_eq!(tags[1].kind, "P");
|
||||
assert_eq!(doc.accessibility_check(&Default::default()).unwrap().result(Rule::TaggedPdf).unwrap().status, Status::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checking_fixing_skipping_and_reporting() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-a11y-ui-{}", std::process::id()));
|
||||
|
||||
+6
-5
@@ -185,15 +185,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: no automatic structure creation
|
||||
### 11. Accessibility: only text-only automatic structure creation
|
||||
|
||||
- **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,
|
||||
- **Missing:** auto-tag mixed text, images, tables, lists, links and fields; connect preview heading
|
||||
proposals to Apply; visual 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;
|
||||
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;
|
||||
`a11y.keyboard-only` (P0) planned, `a11y.app-screen-reader` partial.
|
||||
- **Impact:** untagged files still need another editor to build their structure; PdfCraft itself is
|
||||
- **Impact:** most 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).
|
||||
|
||||
|
||||
+2
-1
@@ -98,7 +98,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; 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 |
|
||||
| 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; 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 |
|
||||
| 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). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. 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 |
|
||||
@@ -146,6 +146,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | M12: text-only auto-tag Apply writes real structure and parent-tree links, with undo and Tags-panel review; mixed content remains unsupported. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. |
|
||||
| 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. |
|
||||
|
||||
@@ -8826,10 +8826,10 @@ area = "M"
|
||||
tier = "P1"
|
||||
milestone = "M12"
|
||||
status = "partial"
|
||||
commands = []
|
||||
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."
|
||||
commands = ["a11y.autotag"]
|
||||
tools = ["accessibility_autotag_preview", "accessibility_autotag_apply"]
|
||||
evidence = ["crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/ui-egui/tests/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."
|
||||
|
||||
[[feature]]
|
||||
id = "a11y.autotag-fields"
|
||||
|
||||
Reference in new issue
Block a user