diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index dd950075e..73f725955 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "48add323da3a6a7643e8019692712e135d57fc996cf8c91397ca155c128a231b" +sha256 = "9976cffa76284284c79e3270dcffac1c393148e8c0ee7113d5793fedcbb698ec" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dacff0f922e0b5c05614dd424e8d2d42ef50ebf88617f9fb46d6397368831c62" +sha256 = "4597a7131ac94a4893e1f84b6585b4f63d54551795c7c17af1f0c7b60178cd38" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "050af73f413f418a884779b200399a1f34c60b23f319376720387c4ab8a68bde" +sha256 = "47af30988b5776a138033de15549df77e198cfe1cc2285e37f94d0af872b259d" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d10d93d5d8a4263f76ec4ff2b422cec460b462811b05e5835bb8e11a8eca420c" +sha256 = "faee91b7cb2cee41b831779ad6309952d399b83709007b336ca116a506525dec" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8f4b89f85ef458195501f8b94c6ccc49f2613d780e5f96a49c6376f326e21255" +sha256 = "13bd6b4176147feafd9b1ddff71aa75778c1e01119537ce5e36f6da3284c89b1" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cccacda996b0864810ca50ab5bbe011f086558d29d3d6b85343c6473774ef3d6" +sha256 = "897fbcf310a56be8b8d5b2b23dfe2c45d4f3c565792c2d28b58161c435d4aa5c" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1ab4346ba91874eddc9800c15a40d46e2399cbbf90bba4481785e25961b14602" +sha256 = "c593532d581c2bae30f0c03be3261256fcae4b703ab154a2cb5e9026247874bd" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "58e9d3d0bdedc03dfd515c767dc7e83d211b54b52783402357c36d9e4012062b" +sha256 = "a724e544044561df9880a68a924dc2225716095c995ebbe93420e0ddd599835a" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1ab3877b027c600ba3cec01523eac90172098ce800ac32fbee93a98a039e0e71" +sha256 = "e92ba4c24b2183d90f007f3d50ef1d90ae10e82aea4d45b8f838967cf7db1bde" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "93a5c2631cfb458bdd06d12b20783c0e9f70d73ef6cc007651824ff04a7ff772" +sha256 = "e1b873432f3bcd2ccadfbdf90b912e47af8d733062d4d34bd0d338f05b57c6c2" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dccace4145bf5be6961e03413e1baac3022b23343fb8ec1bb80e4b771b9b15c1" +sha256 = "7ebdede05e7d63b127232e5e3ebb36220a529f12ad73badbfd2c155ecafb31b6" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "980f345f77d501e1413b734dd343f70a3fc535752a118647e1c3c71719008672" +sha256 = "66897289a489e2b65052d9fb21238e882c6d9cf6652d346b072f0e837367ec23" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ba02397a61f224605fda9fd17416b5e364724c932392ebdda34153310190c07a" +sha256 = "a946a8756b80dbb0505b07dbfe0c01ae4260deab6d3b0beb37b43552eef03536" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d59371e117366bf36ff8627db5abc355d354dc73b61d09e20c78028a492c2518" +sha256 = "0deac0ca8e57642d7d43971fc94d151e500d5234ba0f221a25a0b3754150fef0" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c39f98e74458221d474623a67345e54623307a7cb6396d4927439b5cacfec831" +sha256 = "67aac3be3f312649900310dfdafcdd8233b463e0366185bfe13d97f30eca4df8" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ac27ed3c24f09c5e727ecce94de2313621394457f6cc03b7ebd440331ae62b99" +sha256 = "d2131a65c052b25b72c796bcc235de88c3d662814f015de666a5eb112889944f" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b312eadaea1f306399c9285b528bca282a204c559885546cbd1213509a6f08fe" +sha256 = "320cb208f7e61750480c96e95b3bebe4000efb4bf5218668c3c8d7d630369ec2" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a0f9d57e72d92ff5c8822c38681774b4cd49c6c2a8d174322c3e8e2404bd4e33" +sha256 = "b32f085ffd99fd92e7e2457c44aa51bd7a0831734008fa46c542f4691fa4cb30" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4f0505cf19f18e9152d692861dec6ee9bf1127055e799a65a3305e9d0681a9da" +sha256 = "9ec9155f36d5b5e36ef1b27646ee72dc625cde0e6ba07c54d633d6aaabe45c8f" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f8972322dbe61d739bef4a29e3a4a2a16ac267652375413646273e1c1a917515" +sha256 = "93039a5d4f5b78e29cb6f0212fec4c705fc9a47bc2d89dcf6b00de07d505a608" [[asset]] @@ -240,7 +240,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a05bf2b649ce3fcfa2f990871daa2b97a6464db7b313f606868688806d23f8e1" +sha256 = "4cc6a7220f27ce4fcf9787f56d763b6e2aa4e29a682111fd8ab21dac5689f99e" [[asset]] path = "assets/icons/accessibility.svg" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0c6a8713138e05dd8a2996d7e2da097a03e321e5212a4071bbe58c4c846179bf" +sha256 = "39b64b099db9a6d4d54d1a67f2923dbce0baea9ade737eb8925db8a1faf66ac3" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs new file mode 100644 index 000000000..3203aca37 --- /dev/null +++ b/crates/a11y/src/autotag_fields.rs @@ -0,0 +1,138 @@ +//! Conservative tagging for AcroForm widgets. +//! +//! It creates `/Form` structure elements and `/OBJR` children so assistive technology can find +//! widgets. An existing direct `/Document` root and direct `/ParentTree /Nums` are extended; +//! unusual tree layouts are refused before any edit is made. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +fn reference(object: Option<&Object>, what: &str) -> Result { + object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference")) +} + +fn nums(doc: &Document, parent_tree: ObjRef) -> Result, String> { + let tree = doc.get(parent_tree); + let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?; + tree.get(b"Nums") + .map(|value| doc.resolve(value)) + .as_deref() + .and_then(Object::as_array) + .cloned() + .ok_or_else(|| "the structure parent tree must use a direct Nums array".to_string()) +} + +/// Add `/Form` tags for every page widget that does not already have a structure parent. +/// +/// A new structure tree is written for an untagged form. A conventional existing tree is +/// extended by adding the form tags to its direct `/Document` element and fresh entries to its +/// direct `/ParentTree /Nums` array. Text, figures, links, and non-widget annotations remain +/// outside this operation and need separate review. +pub fn tag_form_fields(doc: &mut Document) -> Result { + let root = doc.root().ok_or("the PDF has no catalog")?; + let catalog_object = doc.get(root); + let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?; + if !catalog.contains(b"AcroForm") { + return Err("the PDF has no AcroForm fields".into()); + } + let existing_root = catalog.get(b"StructTreeRoot").cloned(); + let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default(); + let pages = pdfcraft_model::pages(doc); + let mut widgets = Vec::new(); + // A widget listed in more than one page's /Annots is tagged once (one key, one /Form). + let mut seen = std::collections::HashSet::new(); + for page in &pages { + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default(); + for annot in annots { + let Some(reference) = annot.as_ref() else { continue }; + let resolved = doc.get(reference); + let Some(dict) = resolved.as_dict() else { continue }; + if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) { + widgets.push((page.obj, reference)); + } + } + } + if widgets.is_empty() { + return Err("the AcroForm has no untagged page widgets".into()); + } + + let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root { + let struct_root = reference(Some(&existing_root), "structure tree root")?; + let structure_object = doc.get(struct_root); + let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?; + let document_ref = reference(structure.get(b"K"), "structure tree root K")?; + let document = doc.get(document_ref); + if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) { + return Err("the structure tree root must point directly to a Document element".into()); + } + let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?; + let parent_nums = nums(doc, parent_tree)?; + if parent_nums.len() % 2 != 0 { + return Err("the structure parent tree has an odd number of entries".into()); + } + let mut greatest = -1_i64; + for pair in parent_nums.as_chunks::<2>().0 { + let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) }; + if key < 0 { + return Err("the structure parent tree has a negative key".into()); + } + greatest = greatest.max(key); + } + let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?; + (struct_root, document_ref, parent_tree, parent_nums, first_key, false) + } else { + let mut root_dict = Dict::new(); + root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec())); + let struct_root = doc.add(Object::Dict(root_dict)); + let mut document = Dict::new(); + document.set(b"S".to_vec(), Object::Name(b"Document".to_vec())); + document.set(b"P".to_vec(), Object::Ref(struct_root)); + let document_ref = doc.add(Object::Dict(document)); + let parent_tree = doc.add(Object::Dict(Dict::new())); + (struct_root, document_ref, parent_tree, Vec::new(), 0, true) + }; + let mut kids = Vec::with_capacity(widgets.len()); + for (offset, (page, widget)) in widgets.iter().copied().enumerate() { + let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?; + let mut objr = Dict::new(); + objr.set(b"Type".to_vec(), Object::Name(b"OBJR".to_vec())); + objr.set(b"Obj".to_vec(), Object::Ref(widget)); + objr.set(b"Pg".to_vec(), Object::Ref(page)); + let mut form = Dict::new(); + form.set(b"S".to_vec(), Object::Name(b"Form".to_vec())); + form.set(b"P".to_vec(), Object::Ref(document_ref)); + form.set(b"Pg".to_vec(), Object::Ref(page)); + form.set(b"K".to_vec(), Object::Dict(objr)); + let form_ref = doc.add(Object::Dict(form)); + doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?; + parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]); + kids.push(Object::Ref(form_ref)); + } + doc.update_dict(document_ref, |d| { + let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new())); + let mut all = match old { + Object::Array(items) => items, + item => vec![item], + }; + all.extend(kids); + d.set(b"K".to_vec(), Object::Array(all)); + }) + .map_err(|e| e.to_string())?; + doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?; + if new_tree { + doc.update_dict(struct_root, |d| { + d.set(b"K".to_vec(), Object::Ref(document_ref)); + d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree)); + }) + .map_err(|e| e.to_string())?; + } + let mut mark_info = mark_info; + mark_info.set(b"Marked".to_vec(), Object::Bool(true)); + doc.update_dict(root, |d| { + d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info)); + if new_tree { + d.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root)); + } + }) + .map_err(|e| e.to_string())?; + Ok(widgets.len()) +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index cc3edd60f..77c08e4d6 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -14,6 +14,7 @@ use pdfcraft_cos::Document; mod alt; mod autotag; +mod autotag_fields; mod content; mod headings; mod report; @@ -22,6 +23,7 @@ mod tags; pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; +pub use autotag_fields::tag_form_fields; 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}; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 6f85b8cbf..a3a14a79b 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -96,6 +96,78 @@ fn autotag_text_refuses_paint_and_does_not_modify_the_document() { assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged)); } +#[test] +fn autotag_form_fields_writes_widget_structure_and_parent_tree() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /FT /Tx /T (name) /Kids [6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 60 120 80] >>".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedFormFields].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); + assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed); + let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); + assert!(doc.get(tree).as_dict().unwrap().contains(b"ParentTree")); + let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[1].as_ref().unwrap(); + assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(0)); + let written = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(written)).unwrap(); + assert_eq!(status(&check(&reopened, &Options { rules: [Rule::TaggedFormFields].into(), pages: None }), Rule::TaggedFormFields), Status::Passed); +} + +#[test] +fn autotag_form_fields_tags_a_widget_listed_on_two_pages_once() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 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] /Annots [5 0 R] >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); + let parent_tree = doc.get(tree).as_dict().unwrap().reference(b"ParentTree").unwrap(); + let nums = doc.get(parent_tree).as_dict().unwrap().get(b"Nums").and_then(|n| n.as_array().cloned()).unwrap(); + assert_eq!(nums.len(), 2, "one key and one /Form element for the shared widget"); +} + +#[test] +fn autotag_form_fields_extends_a_direct_existing_structure_tree() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 8 0 R >>".into(), + "<< /S /Document /P 6 0 R /K 9 0 R >>".into(), + "<< /Nums [0 [9 0 R]] >>".into(), + "<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[0].as_ref().unwrap(); + assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(1)); + let report = check(&doc, &Options { rules: [Rule::TaggedFormFields].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed); + let document_object = doc.get(pdfcraft_cos::ObjRef::new(7, 0)); + let document = document_object.as_dict().unwrap(); + assert_eq!(document.get(b"K").and_then(Object::as_array).unwrap().len(), 2, "the existing paragraph and new form remain siblings"); +} + #[test] fn an_untagged_document_fails_the_document_rules() { let doc = pdf( diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 85df2d95d..28b75ca78 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -217,6 +217,19 @@ impl Automation { })) } + pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?; + let report = self + .doc(a)? + .accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None }) + .ok_or_else(|| failed("the document can't be read"))?; + let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?; + Ok( + json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }), + ) + } + pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result { let page = self.page(a)?; let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f2d5cbbed..c83a3b43b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -765,6 +765,7 @@ impl Automation { "accessibility_check" => self.a11y_check(&a)?, "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, + "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "js_run" => self.js_run(&a)?, "js_document_scripts" => self.js_document_scripts(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b6182027..540e99f1a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -928,6 +928,9 @@ pub fn tools() -> Vec { .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_autotag_form_fields", "Tag form fields", "Write `/Form` tags, widget structure-parent keys, and a parent tree for all page widgets in an untagged AcroForm. Existing tag trees are refused for review. Text, figures, links, and other content are not tagged.") + .cmd("a11y.autotag_fields") + .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") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 6e03f7231..e513f09e7 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -291,7 +291,22 @@ fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() { 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")); +} + +#[test] +fn autotag_form_fields_writes_reviewable_widget_tags() { + let dir = workdir("autotag-form-fields"); + std::fs::write(dir.join("field.pdf"), form_pdf("name")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "field.pdf" }))["doc"].as_u64().unwrap(); + let tagged = ok(&mut a, "accessibility_autotag_form_fields", json!({ "doc": doc })); + assert_eq!(tagged["status"], "passed"); + assert_eq!(tagged["remaining_findings"], 0); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "tagged.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "tagged.pdf" }))["doc"].as_u64().unwrap(); + let check = ok(&mut a, "accessibility_check", json!({ "doc": reopened, "rules": ["tagged-form-fields"] })); + let result = check["results"].as_array().unwrap().iter().find(|result| result["rule"] == "tagged-form-fields").unwrap(); + assert_eq!(result["status"], "passed"); } /// A one-page PDF whose form holds one text field named `name`. diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d467ca2cb..d56112498 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -472,6 +472,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ title: "Accessibility", items: &[ item("Automatically tag PDF", "tag", "a11y.autotag", Ready), + item("Tag form fields", "tag", "a11y.autotag_fields", 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), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e3dd581f2..9766edd9f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -336,6 +336,7 @@ pub const COMMANDS: &[CommandSpec] = &[ 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"), + c("a11y.autotag_fields", "Tag form fields", None, None, Modification, "tag"), c("a11y.tags", "Tags panel", None, None, Document, "tag"), c("export.all_images", "Export all images…", FILE, None, Copying, "image"), c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2bca86ab2..8264da23c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1471,6 +1471,8 @@ pub enum Edit { figure: u32, alt: Option, }, + /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. + AutoTagFormFields, /// Change one property on a reachable indirect structure tag. SetTagProperty { object: pdfcraft_cos::ObjRef, @@ -1961,6 +1963,7 @@ impl Edit { Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), Edit::SetTagProperty { .. } => Label::of("Change tag properties"), Edit::MoveTag { .. } => Label::of("Move object"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), @@ -2156,6 +2159,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetInitialView(_) | Edit::AutoTagText | Edit::SetAltText { .. } + | Edit::AutoTagFormFields | Edit::SetTagProperty { .. } | Edit::MoveTag { .. } | Edit::MarkDecorative { .. } @@ -2444,6 +2448,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> 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()))?; } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } Edit::SetTagProperty { object, property, value } => { a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; } diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index 67683beb5..b700536b9 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -608,6 +608,12 @@ pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> } impl PdfCraftApp { + /// Prepare for accessibility ▸ Tag form fields. + pub(crate) fn autotag_form_fields(&mut self) { + self.apply_edit(pdfcraft_engine::Edit::AutoTagFormFields); + self.choose_right_panel(Some(RightPanel::Tags)); + } + /// Prepare for accessibility ▸ Add alternate text. pub(crate) fn start_alt_text(&mut self) { let Some((_, id)) = self.active_ids() else { return }; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 18aee2732..f21c8c309 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -424,6 +424,7 @@ impl PdfCraftApp { } } "a11y.alt_text" => self.start_alt_text(), + "a11y.autotag_fields" => self.autotag_form_fields(), "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), "export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 409c9a000..4ced5c54f 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2394,6 +2394,7 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Tag form fields وضع علامات على حقول النموذج Mark as artifact وضع علامة كعنصر زخرفي Remove this leaf tag from reading order and mark its direct content as an artifact أزل هذه العلامة من ترتيب القراءة وضع علامة على محتواها المباشر كعنصر زخرفي Mark content as artifact وضع علامة على المحتوى كعنصر زخرفي diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8c4335172..2a91b69c7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2336,6 +2336,7 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Tag form fields Маркиране на полетата на формуляра Mark as artifact Маркиране като артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Премахнете този листов етикет от реда на четене и маркирайте прякото му съдържание като артефакт Mark content as artifact Маркиране на съдържание като артефакт diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b1be27536..ad8bacb30 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2447,6 +2447,7 @@ animation effects Off Vypnuto This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: Move tag earlier Přesunout značku dříve Move tag later Přesunout značku později + Tag form fields Označit pole formuláře Mark as artifact Označit jako artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Odeberte tuto značku listu z pořadí čtení a označte její přímý obsah jako artefakt Mark content as artifact Označit obsah jako artefakt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2904d2635..fb841e220 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2374,6 +2374,7 @@ animation effects Off Aus This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: Move tag earlier Tag früher verschieben Move tag later Tag später verschieben + Tag form fields Formularfelder taggen Mark as artifact Als Artefakt markieren Remove this leaf tag from reading order and mark its direct content as an artifact Dieses Blatt-Tag aus der Lesereihenfolge entfernen und seinen direkten Inhalt als Artefakt markieren Mark content as artifact Inhalt als Artefakt markieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index df3981acc..88db08b5f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2383,6 +2383,7 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Tag form fields Επισήμανση πεδίων φόρμας Mark as artifact Σήμανση ως τεχνούργημα Remove this leaf tag from reading order and mark its direct content as an artifact Αφαιρέστε αυτήν την ετικέτα φύλλου από τη σειρά ανάγνωσης και σημειώστε το άμεσο περιεχόμενό της ως τεχνούργημα Mark content as artifact Σήμανση περιεχομένου ως τεχνούργημα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d4153da4..393d48252 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2513,6 +2513,7 @@ animation effects Off Desactivados This document is trying to open a local file: Este documento intenta abrir un archivo local: Move tag earlier Mover etiqueta antes Move tag later Mover etiqueta después + Tag form fields Etiquetar campos de formulario Mark as artifact Marcar como artefacto Remove this leaf tag from reading order and mark its direct content as an artifact Quite esta etiqueta hoja del orden de lectura y marque su contenido directo como artefacto Mark content as artifact Marcar contenido como artefacto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a7a9b8b4d..627af91c6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2373,6 +2373,7 @@ animation effects Off Désactivés This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : Move tag earlier Déplacer la balise plus tôt Move tag later Déplacer la balise plus tard + Tag form fields Baliser les champs de formulaire Mark as artifact Marquer comme artefact Remove this leaf tag from reading order and mark its direct content as an artifact Retirez cette balise feuille de l’ordre de lecture et marquez son contenu direct comme artefact Mark content as artifact Marquer le contenu comme artefact diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cc5290227..0bb1af4fc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2372,6 +2372,7 @@ animation effects Off Ki This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: Move tag earlier Címke korábbra helyezése Move tag later Címke későbbre helyezése + Tag form fields Űrlapmezők címkézése Mark as artifact Megjelölés műtárgyként Remove this leaf tag from reading order and mark its direct content as an artifact Távolítsa el ezt a levélcímkét az olvasási sorrendből, és jelölje meg a közvetlen tartalmát műtárgyként Mark content as artifact Tartalom megjelölése műtárgyként diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3638902a6..244bec46f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2382,6 +2382,7 @@ animation effects Off Disattivati This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: Move tag earlier Sposta tag prima Move tag later Sposta tag dopo + Tag form fields Assegna tag ai campi del modulo Mark as artifact Segna come artefatto Remove this leaf tag from reading order and mark its direct content as an artifact Rimuovi questo tag foglia dall'ordine di lettura e segna il suo contenuto diretto come artefatto Mark content as artifact Segna il contenuto come artefatto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 01ddce01e..c86f3b779 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2373,6 +2373,7 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Tag form fields フォームフィールドにタグを付ける Mark as artifact アーティファクトとしてマーク Remove this leaf tag from reading order and mark its direct content as an artifact このリーフタグを読み上げ順序から削除し、その直接のコンテンツをアーティファクトとしてマークします Mark content as artifact コンテンツをアーティファクトとしてマーク diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 17dec3dd7..7dc74422c 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2371,6 +2371,7 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Tag form fields Пішін өрістерін тегтеу Mark as artifact Артефакт ретінде белгілеу Remove this leaf tag from reading order and mark its direct content as an artifact Осы жапырақ тегін оқу ретінен алып тастап, оның тікелей мазмұнын артефакт ретінде белгілеңіз Mark content as artifact Мазмұнды артефакт ретінде белгілеу diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ce77c5bb9..4ff1061b1 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2617,6 +2617,7 @@ key Space Tarpas This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: Move tag earlier Perkelti žymą anksčiau Move tag later Perkelti žymą vėliau + Tag form fields Žymėti formos laukus Mark as artifact Pažymėti kaip artefaktą Remove this leaf tag from reading order and mark its direct content as an artifact Pašalinkite šią lapo žymą iš skaitymo tvarkos ir pažymėkite jos tiesioginį turinį kaip artefaktą Mark content as artifact Pažymėti turinį kaip artefaktą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 017eff300..92a7a6d77 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2353,6 +2353,7 @@ animation effects Off Wyłączone This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny: Move tag earlier Przenieś znacznik wcześniej Move tag later Przenieś znacznik później + Tag form fields Oznacz pola formularza Mark as artifact Oznacz jako artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Usuń ten znacznik liścia z kolejności odczytu i oznacz jego bezpośrednią zawartość jako artefakt Mark content as artifact Oznacz zawartość jako artefakt diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 96645365d..60d2a8ef4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2387,6 +2387,7 @@ animation effects Off Desativados This document is trying to open a local file: Este documento está tentando abrir um arquivo local: Move tag earlier Mover tag para antes Move tag later Mover tag para depois + Tag form fields Marcar campos de formulário Mark as artifact Marcar como artefato Remove this leaf tag from reading order and mark its direct content as an artifact Remova esta tag folha da ordem de leitura e marque seu conteúdo direto como artefato Mark content as artifact Marcar conteúdo como artefato diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 33b07021b..16486a256 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2286,6 +2286,7 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Tag form fields Пометить поля формы Mark as artifact Пометить как артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Удалите этот конечный тег из порядка чтения и пометьте его непосредственное содержимое как артефакт Mark content as artifact Пометить содержимое как артефакт diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 5ac6ff694..cb4bda4e2 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2378,6 +2378,7 @@ animation effects Off Izklopljeni This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: Move tag earlier Premakni oznako prej Move tag later Premakni oznako pozneje + Tag form fields Označi polja obrazca Mark as artifact Označi kot artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Odstranite to listno oznako iz vrstnega reda branja in označite njeno neposredno vsebino kot artefakt Mark content as artifact Označi vsebino kot artefakt diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index f154a1c98..bf4b5c26c 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2286,6 +2286,7 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Tag form fields ఫారమ్ ఫీల్డ్‌లకు ట్యాగ్‌లు జోడించు Mark as artifact ఆర్టిఫాక్ట్‌గా గుర్తించు Remove this leaf tag from reading order and mark its direct content as an artifact ఈ లీఫ్ ట్యాగ్‌ను పఠన క్రమం నుంచి తీసివేసి, దాని ప్రత్యక్ష కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించండి Mark content as artifact కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e3cc5e57b..3a54aa0c9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2369,6 +2369,7 @@ animation effects Off Kapalı This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: Move tag earlier Etiketi daha erken taşı Move tag later Etiketi daha geç taşı + Tag form fields Form alanlarını etiketle Mark as artifact Yapıt olarak işaretle Remove this leaf tag from reading order and mark its direct content as an artifact Bu yaprak etiketini okuma sırasından kaldırın ve doğrudan içeriğini yapıt olarak işaretleyin Mark content as artifact İçeriği yapıt olarak işaretle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3098b03f6..bbf142029 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2395,6 +2395,7 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Tag form fields Позначити поля форми Mark as artifact Позначити як артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Вилучіть цей кінцевий тег із порядку читання та позначте його безпосередній вміст як артефакт Mark content as artifact Позначити вміст як артефакт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7256df577..e3778fb43 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2592,6 +2592,7 @@ key End End key Space Space Move tag earlier Chuyển thẻ lên trước Move tag later Chuyển thẻ ra sau + Tag form fields Gắn thẻ các trường biểu mẫu Only allow this if you trust the source document. Chỉ cho phép nếu bạn tin tưởng tài liệu nguồn. Open this local PDF? Mở PDF cục bộ này? This document is trying to open a local file: Tài liệu này đang cố mở một tệp cục bộ: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ba337bae7..978c319ea 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2337,6 +2337,7 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Tag form fields 标记表单字段 Mark as artifact 标记为工件 Remove this leaf tag from reading order and mark its direct content as an artifact 从阅读顺序中移除此叶标签,并将其直接内容标记为工件 Mark content as artifact 将内容标记为工件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6884a6aad..3620b394e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2249,6 +2249,7 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Tag form fields 標記表單欄位 Mark as artifact 標記為成品 Remove this leaf tag from reading order and mark its direct content as an artifact 從閱讀順序中移除此葉標記,並將其直接內容標記為成品 Mark content as artifact 將內容標記為成品 diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index e90b2dcf2..484bc52c6 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -18,6 +18,14 @@ endstream endobj trailer << /Root 1 0 R >> %%EOF"; +const FORM_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [20 40 120 60] /P 3 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + fn status(h: &Harness<'static, PdfCraftApp>, rule: Rule) -> Status { h.state().a11y.report.as_ref().unwrap().1.result(rule).unwrap().status } @@ -210,6 +218,28 @@ fn a_saved_page_range_past_the_document_still_checks_its_pages() { assert_eq!(h.state().a11y.options.as_ref().and_then(|o| o.pages.clone()), Some(vec![0])); } +#[test] +fn tagging_form_fields_opens_the_tags_panel() { + 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("form.pdf", None, FORM_FIXTURE.to_vec()).unwrap(); + app + }); + h.run_steps(3); + assert!(h.state_mut().execute("a11y.autotag_fields")); + h.run_steps(3); + assert_eq!(h.state().right, Some(RightPanel::Tags)); + let id = h.state().views[0].id; + let report = h + .state() + .session + .get(id) + .unwrap() + .accessibility_check(&pdfcraft_engine::a11y::Options { rules: [Rule::TaggedFormFields].into(), pages: None }); + assert_eq!(report.unwrap().result(Rule::TaggedFormFields).unwrap().status, Status::Passed); +} + /// Two tagged figures: an image without alternate text and a bar (role-mapped Photo) with. const FIGURES: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj diff --git a/docs/gaps.md b/docs/gaps.md index 9163b1bf4..c006e48d2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -198,8 +198,10 @@ update the numbers. ### 11. Accessibility: only text-only automatic structure creation -- **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, +- **Missing:** apply auto-tag proposals to marked content and a valid parent tree, tag fields + into structure trees with unusual layouts (only a direct Document root with a direct + ParentTree Nums array is extended), + 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 auto-tag preview exist. Apply writes valid structure and parent-tree links for diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 338e2b1aa..e98ab7db1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8876,11 +8876,11 @@ title = "Autotag form fields" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.autotag_fields"] +tools = ["accessibility_autotag_form_fields"] +evidence = ["crates/a11y/src/tests.rs::autotag_form_fields_writes_widget_structure_and_parent_tree", "crates/automation/tests/automation.rs::autotag_form_fields_writes_reviewable_widget_tags"] +notes = "For an untagged AcroForm, writes Form structure elements, OBJR references, widget StructParent keys and ParentTree entries. An existing tree is extended when its root points directly to a Document element and its ParentTree uses a direct Nums array; other layouts are refused before any edit. A widget listed on several pages is tagged once. Text, figures, links and other content remain untagged." [[feature]] id = "a11y.reading-order-tool"