From afa50999ef206d363b2a4c137bdbd3e2c63eaf11 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Thu, 15 Oct 2026 09:24:55 +0200 Subject: [PATCH 01/19] M12: tag unstructured AcroForm widgets (#1451) * M12: tag unstructured AcroForm widgets * i18n: translate form tag command * i18n: repair form-field label merges * fix(i18n): refresh translation catalog hashes * fix(i18n): preserve main translations after rebase * Merge main; tag a widget listed on several pages once, docs say existing trees are extended Co-Authored-By: Claude Opus 5.5 * Merge main; add the Vietnamese catalog entry for Tag form fields Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 ++++---- crates/a11y/src/autotag_fields.rs | 138 ++++++++++++++++++++++++++ crates/a11y/src/lib.rs | 2 + crates/a11y/src/tests.rs | 72 ++++++++++++++ crates/automation/src/a11y.rs | 13 +++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 3 + crates/automation/tests/automation.rs | 17 +++- crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 7 ++ crates/ui-egui/src/a11y_ui.rs | 6 ++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/tests/accessibility.rs | 30 ++++++ docs/gaps.md | 6 +- parity/acrobat-features.toml | 10 +- 38 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 crates/a11y/src/autotag_fields.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index dd950075e7..73f7259557 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 0000000000..3203aca376 --- /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 cc3edd60f1..77c08e4d66 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 6f85b8cbff..a3a14a79b4 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 85df2d95d6..28b75ca78e 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 f2d5cbbed5..c83a3b43bd 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 4b61820271..540e99f1ac 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 6e03f72317..e513f09e7b 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 d467ca2cba..d561124983 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 e3dd581f26..9766edd9f9 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 2bca86ab2e..8264da23c2 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 67683beb58..b700536b9c 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 18aee27327..f21c8c3093 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 409c9a000a..4ced5c54f1 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 8c4335172c..2a91b69c79 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 b1be275367..ad8bacb302 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 2904d26356..fb841e2207 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 df3981acc4..88db08b5fe 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 6d4153da42..393d482527 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 a7a9b8b4d5..627af91c69 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 cc52902274..0bb1af4fca 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 3638902a6f..244bec46fe 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 01ddce01e9..c86f3b7792 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 17dec3dd79..7dc74422c4 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 ce77c5bb98..4ff1061b16 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 017eff300a..92a7a6d775 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 96645365d4..60d2a8ef4f 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 33b07021b9..16486a2565 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 5ac6ff694b..cb4bda4e21 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 f154a1c983..bf4b5c26c4 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 e3cc5e57bd..3a54aa0c9f 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 3098b03f69..bbf1420293 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 7256df577a..e3778fb432 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 ba337bae7a..978c319ea2 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 6884a6aad2..3620b394ef 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 e90b2dcf28..484bc52c64 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 9163b1bf47..c006e48d29 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 338e2b1aa2..e98ab7db16 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" From a6124bcdfe71afa8e391bc597ca1562283a0b0c5 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Thu, 15 Oct 2026 09:33:19 +0200 Subject: [PATCH 02/19] M12: find untagged accessibility content (#1463) * M12: find untagged accessibility content * i18n: translate untagged content checker (rebased) * fix(i18n): refresh untagged content translation hashes * fix(tests): restore full test modules for find-untagged content * fix(i18n): preserve existing translations after rebase * ci: rerun shared timeout test * chore(deps): refresh rust for the advisory gate Use the current lockfile, including the rustls security update, so the dependency check can run on this branch. * Refresh catalog hashes after merging main --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 +++++++++++++-------------- crates/automation/src/a11y.rs | 8 +++++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 4 +++ crates/automation/tests/automation.rs | 20 ++++++++++++ crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/ui-egui/src/a11y_ui.rs | 21 +++++++++++++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 3 ++ crates/ui-egui/src/i18n/bg.tsv | 3 ++ crates/ui-egui/src/i18n/cs.tsv | 3 ++ crates/ui-egui/src/i18n/de.tsv | 3 ++ crates/ui-egui/src/i18n/el.tsv | 3 ++ crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/hu.tsv | 3 ++ crates/ui-egui/src/i18n/it.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/kk.tsv | 3 ++ crates/ui-egui/src/i18n/lt.tsv | 3 ++ crates/ui-egui/src/i18n/pl.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 3 ++ crates/ui-egui/src/i18n/ru.tsv | 3 ++ crates/ui-egui/src/i18n/sl.tsv | 3 ++ crates/ui-egui/src/i18n/te.tsv | 3 ++ crates/ui-egui/src/i18n/tr.tsv | 3 ++ crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/i18n/vi.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/tests/accessibility.rs | 17 +++++++++++ parity/acrobat-features.toml | 10 +++--- 33 files changed, 167 insertions(+), 27 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 73f7259557..fafd032489 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 = "9976cffa76284284c79e3270dcffac1c393148e8c0ee7113d5793fedcbb698ec" +sha256 = "7d1ae0d63cff95e85eea863831791c0a967a908e4062ab220ff2f3b0b77aee96" # 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 = "4597a7131ac94a4893e1f84b6585b4f63d54551795c7c17af1f0c7b60178cd38" +sha256 = "3d558b2d0ed089b1f554d9aa8d66c56a5e91f8f3ceb33031dc68bc8d511d4eb6" [[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 = "47af30988b5776a138033de15549df77e198cfe1cc2285e37f94d0af872b259d" +sha256 = "4857aa037856ff8e2ba82dea4b630f42c611d6817c9677bf2c6a8133c57927f8" [[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 = "faee91b7cb2cee41b831779ad6309952d399b83709007b336ca116a506525dec" +sha256 = "074d09497b6407d978f3ffd05cac81d52d1ddfe6c4ce8b0f564a00a203ec4579" [[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 = "13bd6b4176147feafd9b1ddff71aa75778c1e01119537ce5e36f6da3284c89b1" +sha256 = "69c2fb412e7b5a7ef5ab9740ba9b29ba4e9509668623c7eef298d8cc06ad1068" [[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 = "897fbcf310a56be8b8d5b2b23dfe2c45d4f3c565792c2d28b58161c435d4aa5c" +sha256 = "9b32ef11741bca8b79d51daacfda0690ea0ec829bb2691d9b60543ccff2ccede" [[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 = "c593532d581c2bae30f0c03be3261256fcae4b703ab154a2cb5e9026247874bd" +sha256 = "b6a451c484551d270b5efc42d91984d16fcc5d7c09930cc5a5339d430a7b96f9" [[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 = "a724e544044561df9880a68a924dc2225716095c995ebbe93420e0ddd599835a" +sha256 = "28521cc75735a14a7b44f02d67a46ae54821f9f19e8d352513213ffcf529d7ee" [[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 = "e92ba4c24b2183d90f007f3d50ef1d90ae10e82aea4d45b8f838967cf7db1bde" +sha256 = "db0205e928f498b0674fb84c604b45c7c83d23f170ee98c8d8a807c56422eb3d" [[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 = "e1b873432f3bcd2ccadfbdf90b912e47af8d733062d4d34bd0d338f05b57c6c2" +sha256 = "817fce18112db6084a03405d58ae110ed90d7fddc014b6c0af10a5e79ef4d906" [[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 = "7ebdede05e7d63b127232e5e3ebb36220a529f12ad73badbfd2c155ecafb31b6" +sha256 = "e9b507a72ac306bac9c3b6f4bb3917064b811fe60eb0041cda0b26349d469e95" [[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 = "66897289a489e2b65052d9fb21238e882c6d9cf6652d346b072f0e837367ec23" +sha256 = "c400f2e8bd7cd0a49ac31c2c4fd4a2fab1a50a539e0a68ebba4cc028427e0fbb" [[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 = "a946a8756b80dbb0505b07dbfe0c01ae4260deab6d3b0beb37b43552eef03536" +sha256 = "c76086602063239d59fd8e54b2ea282dae1b22f7118437e4358ce3c35cc8f435" [[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 = "0deac0ca8e57642d7d43971fc94d151e500d5234ba0f221a25a0b3754150fef0" +sha256 = "3222d3448ac0d8020dfe5d8a0b3c5e3b6d93a26c54105f996de056569bfa64f4" [[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 = "67aac3be3f312649900310dfdafcdd8233b463e0366185bfe13d97f30eca4df8" +sha256 = "48480d411ecd9923a1c5ceae5ca8925a5a2eb660cdacd08df77054ce3a014db8" [[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 = "d2131a65c052b25b72c796bcc235de88c3d662814f015de666a5eb112889944f" +sha256 = "3e09f5942b85f677b0cf8b3b326a2ca1896eae205c1c86c283891148f1b2dca4" [[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 = "320cb208f7e61750480c96e95b3bebe4000efb4bf5218668c3c8d7d630369ec2" +sha256 = "d28704a4803f764ffa52f308332636c9846832c4cf3a22093b9c1253afe512e6" [[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 = "b32f085ffd99fd92e7e2457c44aa51bd7a0831734008fa46c542f4691fa4cb30" +sha256 = "a5f8e4e269f3ee2f466ed8bf2e2bb629fd0e79f149f0a536d7f64c26919b07b4" [[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 = "9ec9155f36d5b5e36ef1b27646ee72dc625cde0e6ba07c54d633d6aaabe45c8f" +sha256 = "7be55c126ff56daf887ef1faf5bce8e0630dcc791adf5a57d772f28a3060aca7" [[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 = "93039a5d4f5b78e29cb6f0212fec4c705fc9a47bc2d89dcf6b00de07d505a608" +sha256 = "137d522b0b8746519a8d2cfaad3908d4fa3e4fd80eb6a1233a4c693d4492e7aa" [[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 = "4cc6a7220f27ce4fcf9787f56d763b6e2aa4e29a682111fd8ab21dac5689f99e" +sha256 = "5a14dc07d02b4b5ca6c5e06cfe80f459d195791bf42f0f2a75a73fc8292f57b1" [[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 = "39b64b099db9a6d4d54d1a67f2923dbce0baea9ade737eb8925db8a1faf66ac3" +sha256 = "ddaec4954dd6b46e089ae31da9c012d10a03da33ee138b07793e869f3813d5c3" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 28b75ca78e..90d308ea9e 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -286,6 +286,14 @@ impl Automation { Ok(report_json(&r)) } + /// Report only untagged page content, annotations, and AcroForm widgets. + pub(crate) fn a11y_find_untagged(&self, a: &Args) -> Result { + let pages = a.opt_ints("pages")?.map(|_| self.pages(a, "pages")).transpose()?; + let rules = [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields].into_iter().collect(); + let report = self.doc(a)?.accessibility_check(&Options { rules, pages }).ok_or_else(|| failed("the document can't be read for checking"))?; + Ok(report_json(&report)) + } + pub(crate) fn a11y_report(&self, a: &Args) -> Result { let o = self.a11y_options(a)?; let doc = self.doc(a)?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c83a3b43bd..b32c22bce6 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -763,6 +763,7 @@ impl Automation { "comment_set_status" => self.comment_set_status(&a)?, "sign_list" => self.sign_list(&a)?, "accessibility_check" => self.a11y_check(&a)?, + "accessibility_find_untagged" => self.a11y_find_untagged(&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)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 540e99f1ac..f2bb9611db 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -944,6 +944,10 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("accessibility_find_untagged", "Find untagged content", "Find untagged painted page content, annotations, and form widgets. This focused check does not modify the PDF or claim that its other accessibility rules pass.") + .ro() + .cmd("a11y.find_untagged") + .with(schema(json!({ "doc": doc(), "pages": pages("to search (default: all)") }), &["doc"])), t("accessibility_report", "Accessibility report", "Run the full check and write the accessibility report (HTML) to path; returns the results too. Takes the same options as accessibility_check.") .destructive() .cmd("a11y.report") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index e513f09e7b..58f4a5272d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -6387,6 +6387,26 @@ fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() { assert_eq!(opened["destination_applied"], true); } +#[test] +fn finding_untagged_content_limits_results_to_content_annotations_and_widgets() { + let dir = workdir("find-untagged"); + 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 found = ok(&mut a, "accessibility_find_untagged", json!({ "doc": doc })); + let active: Vec<&str> = found["results"] + .as_array() + .unwrap() + .iter() + .filter(|result| result["status"] != "skipped") + .map(|result| result["rule"].as_str().unwrap()) + .collect(); + assert_eq!(active, ["tagged-content", "tagged-annotations", "tagged-form-fields"]); + let fields = found["results"].as_array().unwrap().iter().find(|result| result["rule"] == "tagged-form-fields").unwrap(); + assert_eq!(fields["status"], "failed"); + assert_eq!(fields["findings"][0]["page"], 1); +} + #[test] fn a_missing_path_under_the_root_names_where_it_was_looked_for() { // #1890: a script written from another working directory repeats the root's folder in its diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d561124983..b46d88acf1 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -475,6 +475,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ 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("Find untagged content", "search", "a11y.find_untagged", Ready), item("Open accessibility report", "file-text", "a11y.report", Ready), item("Add alternate text", "image", "a11y.alt_text", Ready), item("Tags panel", "tag", "a11y.tags", Ready), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9766edd9f9..b28758e241 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -332,6 +332,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.find_untagged", "Find untagged content", None, None, Document, "search"), 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"), diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index b700536b9c..2130c927a0 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -343,6 +343,27 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D } impl PdfCraftApp { + /// Prepare for accessibility ▸ Find untagged content. + pub(crate) fn find_untagged_content(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let options = Options { rules: [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields].into_iter().collect(), pages: None }; + let Some(report) = doc.accessibility_check(&options) else { + self.notify_tr("This document can't be checked: it couldn't be read for editing"); + return; + }; + let failed = report.count(Status::Failed); + self.a11y.previous = None; + self.a11y.options = Some(options); + self.a11y.report = Some((id, report)); + self.right = Some(RightPanel::Accessibility); + self.notify(if failed == 0 { + tl!("No untagged content was found.").to_string() + } else { + crate::i18n::fmt(tl!("Found {n} untagged content problems."), &[("n", &failed.to_string())]) + }); + } + /// Check for accessibility: the options first, unless they are turned off. pub(crate) fn start_accessibility_check(&mut self) { if self.a11y_options.show_dialog { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index f21c8c3093..c46d93d624 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -395,6 +395,7 @@ impl PdfCraftApp { "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), "a11y.check" => self.start_accessibility_check(), + "a11y.find_untagged" => self.find_untagged_content(), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), "tools.js_console" => self.dialog = Some(Dialog::JsConsole), "tools.document_js" => { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 4ced5c54f1..ccb7bda432 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2394,6 +2394,9 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Find untagged content العثور على محتوى غير مُعلَّم + No untagged content was found. لم يتم العثور على محتوى غير معلَّم. + Found {n} untagged content problems. تم العثور على {n} مشكلة في محتوى غير معلَّم. Tag form fields وضع علامات على حقول النموذج Mark as artifact وضع علامة كعنصر زخرفي Remove this leaf tag from reading order and mark its direct content as an artifact أزل هذه العلامة من ترتيب القراءة وضع علامة على محتواها المباشر كعنصر زخرفي diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2a91b69c79..2c2c67d3fc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2336,6 +2336,9 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Find untagged content Намиране на немаркирано съдържание + No untagged content was found. Не е намерено немаркирано съдържание. + Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание. Tag form fields Маркиране на полетата на формуляра Mark as artifact Маркиране като артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Премахнете този листов етикет от реда на четене и маркирайте прякото му съдържание като артефакт diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ad8bacb302..36aed74abc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2447,6 +2447,9 @@ 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 + Find untagged content Najít neoznačený obsah + No untagged content was found. Nebyl nalezen žádný neoznačený obsah. + Found {n} untagged content problems. Nalezeno problémů s neoznačeným obsahem: {n}. 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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fb841e2207..748e514b1f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2374,6 +2374,9 @@ 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 + Find untagged content Nicht markierten Inhalt suchen + No untagged content was found. Es wurde kein unmarkierter Inhalt gefunden. + Found {n} untagged content problems. {n} Probleme mit unmarkiertem Inhalt gefunden. 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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 88db08b5fe..ff654f3119 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2383,6 +2383,9 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Find untagged content Εύρεση μη επισημασμένου περιεχομένου + No untagged content was found. Δεν βρέθηκε μη επισημασμένο περιεχόμενο. + Found {n} untagged content problems. Βρέθηκαν {n} προβλήματα μη επισημασμένου περιεχομένου. Tag form fields Επισήμανση πεδίων φόρμας Mark as artifact Σήμανση ως τεχνούργημα Remove this leaf tag from reading order and mark its direct content as an artifact Αφαιρέστε αυτήν την ετικέτα φύλλου από τη σειρά ανάγνωσης και σημειώστε το άμεσο περιεχόμενό της ως τεχνούργημα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 393d482527..602dd2316d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2513,6 +2513,9 @@ 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 + Find untagged content Buscar contenido sin etiquetar + No untagged content was found. No se encontró contenido sin etiquetar. + Found {n} untagged content problems. Se encontraron {n} problemas de contenido sin etiquetar. 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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 627af91c69..bbead473bf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2373,6 +2373,9 @@ 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 + Find untagged content Rechercher le contenu non balisé + No untagged content was found. Aucun contenu non balisé n’a été trouvé. + Found {n} untagged content problems. {n} problèmes de contenu non balisé trouvés. 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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0bb1af4fca..5ac50c8e7b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2372,6 +2372,9 @@ 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 + Find untagged content Címkézetlen tartalom keresése + No untagged content was found. Nem található címkézetlen tartalom. + Found {n} untagged content problems. {n} címkézetlen tartalmi probléma található. 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 244bec46fe..5137597430 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2382,6 +2382,9 @@ 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 + Find untagged content Trova contenuto senza tag + No untagged content was found. Non è stato trovato contenuto senza tag. + Found {n} untagged content problems. Trovati {n} problemi di contenuto senza tag. 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c86f3b7792..2392d4b258 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2373,6 +2373,9 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Find untagged content タグなしのコンテンツを検索 + No untagged content was found. タグなしのコンテンツは見つかりませんでした。 + Found {n} untagged content problems. タグなしのコンテンツの問題が {n} 件見つかりました。 Tag form fields フォームフィールドにタグを付ける Mark as artifact アーティファクトとしてマーク Remove this leaf tag from reading order and mark its direct content as an artifact このリーフタグを読み上げ順序から削除し、その直接のコンテンツをアーティファクトとしてマークします diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 7dc74422c4..2a0fc74617 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2371,6 +2371,9 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Find untagged content Тегтелмеген мазмұнды табу + No untagged content was found. Тегтелмеген мазмұн табылмады. + Found {n} untagged content problems. Тегтелмеген мазмұнға қатысты {n} мәселе табылды. Tag form fields Пішін өрістерін тегтеу Mark as artifact Артефакт ретінде белгілеу Remove this leaf tag from reading order and mark its direct content as an artifact Осы жапырақ тегін оқу ретінен алып тастап, оның тікелей мазмұнын артефакт ретінде белгілеңіз diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 4ff1061b16..29d6b9ed5e 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2617,6 +2617,9 @@ 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 + Find untagged content Rasti nepažymėtą turinį + No untagged content was found. Nepažymėto turinio nerasta. + Found {n} untagged content problems. Rasta nepažymėto turinio problemų: {n}. 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ą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 92a7a6d775..0010dffa2b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2353,6 +2353,9 @@ 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 + Find untagged content Znajdź nieoznaczoną zawartość + No untagged content was found. Nie znaleziono nieoznaczonej zawartości. + Found {n} untagged content problems. Znaleziono problemów z nieoznaczoną zawartością: {n}. 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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 60d2a8ef4f..8f7695a04a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2387,6 +2387,9 @@ 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 + Find untagged content Encontrar conteúdo sem marcação + No untagged content was found. Nenhum conteúdo sem marcação foi encontrado. + Found {n} untagged content problems. Foram encontrados {n} problemas de conteúdo sem marcação. 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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 16486a2565..8d14595bed 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2286,6 +2286,9 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Find untagged content Найти нетегированный контент + No untagged content was found. Нетегированный контент не найден. + Found {n} untagged content problems. Найдено проблем с нетегированным контентом: {n}. Tag form fields Пометить поля формы Mark as artifact Пометить как артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Удалите этот конечный тег из порядка чтения и пометьте его непосредственное содержимое как артефакт diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index cb4bda4e21..6c33abc668 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2378,6 +2378,9 @@ 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 + Find untagged content Poišči neoznačeno vsebino + No untagged content was found. Neoznačene vsebine ni bilo mogoče najti. + Found {n} untagged content problems. Najdenih je {n} težav z neoznačeno vsebino. 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 diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index bf4b5c26c4..9431eb1222 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2286,6 +2286,9 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Find untagged content ట్యాగ్ చేయని కంటెంట్‌ను కనుగొను + No untagged content was found. ట్యాగ్ చేయని కంటెంట్ కనుగొనబడలేదు. + Found {n} untagged content problems. ట్యాగ్ చేయని కంటెంట్‌లో {n} సమస్యలు కనుగొనబడ్డాయి. Tag form fields ఫారమ్ ఫీల్డ్‌లకు ట్యాగ్‌లు జోడించు Mark as artifact ఆర్టిఫాక్ట్‌గా గుర్తించు Remove this leaf tag from reading order and mark its direct content as an artifact ఈ లీఫ్ ట్యాగ్‌ను పఠన క్రమం నుంచి తీసివేసి, దాని ప్రత్యక్ష కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించండి diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3a54aa0c9f..863bf28e31 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2369,6 +2369,9 @@ 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şı + Find untagged content Etiketlenmemiş içeriği bul + No untagged content was found. Etiketlenmemiş içerik bulunamadı. + Found {n} untagged content problems. {n} etiketlenmemiş içerik sorunu bulundu. 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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbf1420293..9b893f0bf9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2395,6 +2395,9 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Find untagged content Знайти нетегований вміст + No untagged content was found. Нетегованого вмісту не знайдено. + Found {n} untagged content problems. Знайдено проблем із нетегованим вмістом: {n}. Tag form fields Позначити поля форми Mark as artifact Позначити як артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Вилучіть цей кінцевий тег із порядку читання та позначте його безпосередній вміст як артефакт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e3778fb432..ded03716c6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2602,3 +2602,6 @@ key Space Space Restore Khôi phục Use system title bar Dùng thanh tiêu đề hệ thống Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Hiện thanh tiêu đề đơn giản cho các nút cửa sổ thay vì dải tab. GNOME không vẽ thanh tiêu đề cho ứng dụng, nên thanh này không theo chủ đề của nó. + Find untagged content Tìm nội dung chưa được gắn thẻ + No untagged content was found. Không tìm thấy nội dung nào chưa được gắn thẻ. + Found {n} untagged content problems. Đã tìm thấy {n} vấn đề về nội dung chưa được gắn thẻ. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 978c319ea2..65c78c4a2b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2337,6 +2337,9 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Find untagged content 查找未标记的内容 + No untagged content was found. 未找到未标记的内容。 + Found {n} untagged content problems. 发现 {n} 个未标记内容问题。 Tag form fields 标记表单字段 Mark as artifact 标记为工件 Remove this leaf tag from reading order and mark its direct content as an artifact 从阅读顺序中移除此叶标签,并将其直接内容标记为工件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3620b394ef..38e65dcc4f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2249,6 +2249,9 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Find untagged content 尋找未標記的內容 + No untagged content was found. 找不到未標記的內容。 + Found {n} untagged content problems. 發現 {n} 個未標記內容問題。 Tag form fields 標記表單欄位 Mark as artifact 標記為成品 Remove this leaf tag from reading order and mark its direct content as an artifact 從閱讀順序中移除此葉標記,並將其直接內容標記為成品 diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index 484bc52c64..a37098be80 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -416,3 +416,20 @@ fn checker_finding_opens_its_structure_tag() { h.run_steps(2); assert_eq!(h.state().right, Some(RightPanel::Accessibility), "an unfinished tag edit is preserved"); } + +#[test] +fn finding_untagged_content_shows_the_focused_accessibility_results() { + 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.find_untagged")); + h.run_steps(2); + assert_eq!(h.state().right, Some(RightPanel::Accessibility)); + let report = &h.state().a11y.report.as_ref().unwrap().1; + let active: Vec = report.results.iter().filter(|result| result.status != Status::Skipped).map(|result| result.rule).collect(); + assert_eq!(active, [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e98ab7db16..779c81f872 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8996,11 +8996,11 @@ title = "Find unmarked content, comments and links" area = "M" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.find_untagged"] +tools = ["accessibility_find_untagged"] +evidence = ["crates/automation/tests/automation.rs::finding_untagged_content_limits_results_to_content_annotations_and_widgets", "crates/ui-egui/tests/accessibility.rs::finding_untagged_content_shows_the_focused_accessibility_results"] +notes = "Lists findings for untagged painted page content, non-widget annotations and form widgets, optionally on selected pages. It reuses the checker and does not identify untagged table structure, links already represented by an annotation, or structure-tree elements that lack content." [[feature]] id = "a11y.table-editor" From 24f93e61027e8ed6ed108d0d89ac98e607738b37 Mon Sep 17 00:00:00 2001 From: Luis Araya <35828647+Totopo27@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:33:43 -0600 Subject: [PATCH 03/19] feat(ui): preference to hide author-only highlight hover previews (#1432) (#1466) * feat(ui): preference to hide author-only highlight hover previews (#1432) * Keep the Identity heading, translate the new hover preference, move its persist key out of json! The heading rename dropped the existing Identity translation in every catalog; the new checkbox label now has an entry in every catalog. The json! object in persist() is at serde_json's recursion limit, so the new key is set after it like redact_prefs. Co-Authored-By: Claude Opus 5.5 * cargo fmt Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 ++++++++++++++--------------- crates/ui-egui/src/canvas.rs | 2 ++ crates/ui-egui/src/comments.rs | 3 ++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/js_ui.rs | 1 + crates/ui-egui/src/lib.rs | 12 ++++++++ crates/ui-egui/tests/comments.rs | 31 ++++++++++++++++++++ 28 files changed, 93 insertions(+), 22 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index fafd032489..5f1e2451c9 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 = "7d1ae0d63cff95e85eea863831791c0a967a908e4062ab220ff2f3b0b77aee96" +sha256 = "3476a5c81ffadc4583688aa6d3bc4456b2b13a16d8daf8cd2b650e764d89ff9d" # 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 = "3d558b2d0ed089b1f554d9aa8d66c56a5e91f8f3ceb33031dc68bc8d511d4eb6" +sha256 = "c5651b392263a30db0cd6c086e132edcc3ac4fab9a64fd786161bd5917c8d7bb" [[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 = "4857aa037856ff8e2ba82dea4b630f42c611d6817c9677bf2c6a8133c57927f8" +sha256 = "de71e41c22a8154ea46da3ecab69f5b972c8c8f81fa4393e12e449ab598e000f" [[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 = "074d09497b6407d978f3ffd05cac81d52d1ddfe6c4ce8b0f564a00a203ec4579" +sha256 = "cbfa1b7938f52b6d39cec95612bea3bbff5822e2d62f2e1e9871b92bab7efd34" [[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 = "69c2fb412e7b5a7ef5ab9740ba9b29ba4e9509668623c7eef298d8cc06ad1068" +sha256 = "018fd6acbab584c8fa1b49d5950e03e4209f9d073ea83d23bd57c5a114ef35d7" [[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 = "9b32ef11741bca8b79d51daacfda0690ea0ec829bb2691d9b60543ccff2ccede" +sha256 = "fa5e2462162d403c2c2d341a8d8534945724215dd21f6585ff79797dbdefb47a" [[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 = "b6a451c484551d270b5efc42d91984d16fcc5d7c09930cc5a5339d430a7b96f9" +sha256 = "d5a3a04bf5cd9ddb2e67d5e4c938508c3f6536806f8a476445c5fea90d275e4c" [[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 = "28521cc75735a14a7b44f02d67a46ae54821f9f19e8d352513213ffcf529d7ee" +sha256 = "1a30e7994b3dfbcca19a6a8a3c7fde8593b4bf7bb546bd2b266b274f3a224c2e" [[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 = "db0205e928f498b0674fb84c604b45c7c83d23f170ee98c8d8a807c56422eb3d" +sha256 = "3bd44d5996cebc76481d9dd2ded478bbbe2cf5fd24892ad8702a2ed059cbbfa9" [[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 = "817fce18112db6084a03405d58ae110ed90d7fddc014b6c0af10a5e79ef4d906" +sha256 = "0dd303b34e6ccbbed8c030e876a696bf555b8d33abac55ca02e415a5a987f067" [[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 = "e9b507a72ac306bac9c3b6f4bb3917064b811fe60eb0041cda0b26349d469e95" +sha256 = "7d59c4324d82ad3be522ce6cff16e5101334b6d816e8d920c529ba2151aa4233" [[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 = "c400f2e8bd7cd0a49ac31c2c4fd4a2fab1a50a539e0a68ebba4cc028427e0fbb" +sha256 = "7f1b964b5ac341c3d6e8b3328a319a2c9d7e6ca358255a155fb3499cb9bb9ee8" [[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 = "c76086602063239d59fd8e54b2ea282dae1b22f7118437e4358ce3c35cc8f435" +sha256 = "c1404eee8a87dde7d0900eb61f945518281e17edae26cc5615dc64e7f3335ee9" [[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 = "3222d3448ac0d8020dfe5d8a0b3c5e3b6d93a26c54105f996de056569bfa64f4" +sha256 = "433e5246288f0d27ed10463fe1c33461493a32f2f793684b001546678ade2195" [[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 = "48480d411ecd9923a1c5ceae5ca8925a5a2eb660cdacd08df77054ce3a014db8" +sha256 = "70cd926ae324621d24b6c75519ccab8dfa88f7b6a64a5d52bcf8b115d3ad8e27" [[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 = "3e09f5942b85f677b0cf8b3b326a2ca1896eae205c1c86c283891148f1b2dca4" +sha256 = "fc29daaff6f6377ab0a9e76a64995a0210f894251872d0d9a9a59e721bf542e1" [[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 = "d28704a4803f764ffa52f308332636c9846832c4cf3a22093b9c1253afe512e6" +sha256 = "cfa46745d42d4d4e14f6b3d941d83006129ae331e47a87a3ef673087ad7cb292" [[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 = "a5f8e4e269f3ee2f466ed8bf2e2bb629fd0e79f149f0a536d7f64c26919b07b4" +sha256 = "7ee8a57e49498a4a062d04d995a1129034c8e006defaf927460cf5dcf3c19e0e" [[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 = "7be55c126ff56daf887ef1faf5bce8e0630dcc791adf5a57d772f28a3060aca7" +sha256 = "398c94f07f44ba96f803e3ac64faf606562c5c9a5dc02765c1ed10a648c7251e" [[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 = "137d522b0b8746519a8d2cfaad3908d4fa3e4fd80eb6a1233a4c693d4492e7aa" +sha256 = "7affbbad51af9b4fd54aec7fb118500a81f145eea79070a61e645f989449560b" [[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 = "5a14dc07d02b4b5ca6c5e06cfe80f459d195791bf42f0f2a75a73fc8292f57b1" +sha256 = "1ea1b1ebc84ad7f84efafd6777469259709dc9f4c9ed93147f2522433d42ff5c" [[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 = "ddaec4954dd6b46e089ae31da9c012d10a03da33ee138b07793e869f3813d5c3" +sha256 = "7a4558dc2e1fdbfc254b823839db49d05c6113fdf3414d250895b3063d916bba" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 22a96274ce..1d0f71c013 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3222,8 +3222,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let gesturing = view.comments.gesture.is_some() || freehand; for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) { let sr = xf.user_rect(info, i, a.rect); + let hidden = prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty); if sr.contains(p) && hover_text.is_none() + && !hidden && let Some(text) = annotation_hover_text(a) { hover_text = Some((p, text)); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 37adb1ea0f..3bdbf5a2c0 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -250,6 +250,8 @@ pub struct CommentPrefs { pub pinned: bool, /// The tool each flyout group shows (the last one used). pub group_tool: [CommentTool; 3], + /// Hide hover previews on highlight annotations when they contain no comment text beyond the author. + pub hide_empty_highlight_hover: bool, } impl Default for CommentPrefs { @@ -259,6 +261,7 @@ impl Default for CommentPrefs { styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(), pinned: false, group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink], + hide_empty_highlight_hover: false, } } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ccb7bda432..7c2cfab006 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -34,6 +34,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. يتبع الوضع التلقائي حجم النص في النظام. تُعرض الصفحات دائمًا بالدقة الكاملة للشاشة. Identity الهوية Name on new comments الاسم على التعليقات الجديدة + Hide hover previews for highlights without comment text إخفاء معاينات التمرير للتظليلات التي لا تحتوي على نص تعليق Open… فتح… Open Recent فتح الملفات الأخيرة Pin folder to Home… تثبيت مجلد في الصفحة الرئيسية… diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2c2c67d3fc..6fc9ddb7d8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -60,6 +60,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Автоматично следва размера на текста в системата. Страниците винаги се изобразяват с пълната разделителна способност на екрана. Identity Самоличност Name on new comments Име в новите коментари + Hide hover previews for highlights without comment text Скриване на прегледите при посочване за маркирания без текст на коментар Open… Отвори… Open Recent Последно отваряни Pin folder to Home… Закачи папка в Начало… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 36aed74abc..1571b5ab83 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -40,6 +40,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Automaticky se řídí velikostí textu v systému. Stránky se vždy vykreslují v plném rozlišení displeje. Identity Identita Name on new comments Jméno autora nových komentářů + Hide hover previews for highlights without comment text Skrýt náhledy při najetí u zvýraznění bez textu komentáře Enable Acrobat JavaScript Povolit Acrobat JavaScript OK OK Open… Otevřít… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 748e514b1f..9d6ad17e55 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -23,6 +23,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Automatisch folgt der Textgröße des Systems. Seiten werden immer in der vollen Auflösung des Bildschirms dargestellt. Identity Identität Name on new comments Name für neue Kommentare + Hide hover previews for highlights without comment text Hover-Vorschau für Hervorhebungen ohne Kommentartext ausblenden Open… Öffnen… New blank PDF Neues leeres PDF Create PDF from file… PDF aus Datei erstellen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ff654f3119..7b6a32fe89 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -16,6 +16,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Η αυτόματη ρύθμιση ακολουθεί το μέγεθος κειμένου του συστήματος. Οι σελίδες αποδίδονται πάντα στην πλήρη ανάλυση της οθόνης. Identity Ταυτότητα Name on new comments Όνομα στα νέα σχόλια + Hide hover previews for highlights without comment text Απόκρυψη προεπισκοπήσεων κατά την αιώρηση για επισημάνσεις χωρίς κείμενο σχολίου Open… Άνοιγμα… Open Recent Άνοιγμα πρόσφατων Clear Recent Files Εκκαθάριση πρόσφατων αρχείων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 602dd2316d..dc36934c01 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -23,6 +23,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Automático sigue el tamaño del texto del sistema. Las páginas siempre se muestran con la resolución completa de la pantalla. Identity Identidad Name on new comments Nombre en los comentarios nuevos + Hide hover previews for highlights without comment text Ocultar las vistas previas al pasar el puntero en resaltados sin texto de comentario Open… Abrir… Open Recent Abrir recientes Clear Recent Files Borrar archivos recientes diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bbead473bf..437654d083 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -23,6 +23,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Automatique suit la taille du texte du système. Les pages sont toujours rendues à la pleine résolution de l'écran. Identity Identité Name on new comments Nom sur les nouveaux commentaires + Hide hover previews for highlights without comment text Masquer les aperçus au survol des surlignages sans texte de commentaire Open… Ouvrir… New blank PDF Nouveau PDF vierge Create PDF from file… Créer un PDF à partir d'un fichier… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5ac50c8e7b..c058424eb8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -23,6 +23,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Az Automatikus beállítás a rendszer szövegméretét követi. Az oldalak mindig a kijelző teljes felbontásában jelennek meg. Identity Személyazonosság Name on new comments Név az új megjegyzéseken + Hide hover previews for highlights without comment text Megjegyzésszöveg nélküli kiemelések előnézetének elrejtése rámutatáskor Open… Megnyitás… New blank PDF Új üres PDF Create PDF from file… PDF létrehozása fájlból… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5137597430..4f750fd56d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -29,6 +29,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Auto segue la dimensione del testo del sistema. Le pagine vengono sempre visualizzate alla piena risoluzione dello schermo. Identity Identità Name on new comments Nome sui nuovi commenti + Hide hover previews for highlights without comment text Nascondi le anteprime al passaggio del puntatore per le evidenziazioni senza testo di commento Open… Apri… Open Recent Apri recenti Pin folder to Home… Fissa una cartella sulla Home… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2392d4b258..36b586f1a0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -29,6 +29,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. 自動にすると、システムの文字サイズに合わせます。ページは常にディスプレイの最大解像度で描画されます。 Identity 個人情報 Name on new comments 新しい注釈の作成者名 + Hide hover previews for highlights without comment text コメントテキストのないハイライトのホバープレビューを非表示にする Open… 開く… Open Recent 最近のファイルを開く Clear Recent Files 最近のファイルをクリア diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 2a0fc74617..514dab8cfe 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -9,6 +9,7 @@ Interface language Интерфейс тілі Identity Жеке деректер Name on new comments Жаңа түсініктемелердегі аты + Hide hover previews for highlights without comment text Түсініктеме мәтіні жоқ белгілеулер үшін меңзер алдын ала қарауын жасыру Open… Ашу… Open Recent Соңғы файлдарды ашу Pin folder to Home… Қалтаны Басты бетке бекіту… diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 29d6b9ed5e..b5068417f1 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -22,6 +22,7 @@ Auto ({percent}%) Automatiškai ({percent} %) Identity Tapatybė Name on new comments Naujų komentarų autoriaus vardas + Hide hover previews for highlights without comment text Slėpti užvedimo peržiūras paryškinimams be komentaro teksto Open… Atverti… Open Recent Atverti paskiausiai naudotus failus Clear Recent Files Išvalyti paskiausiai naudotų failų sąrašą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0010dffa2b..ce9f9bb3ca 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -26,6 +26,7 @@ Interface language Język interfejsu Identity Tożsamość Name on new comments Autor nowych komentarzy + Hide hover previews for highlights without comment text Ukryj podglądy po najechaniu dla wyróżnień bez tekstu komentarza Open… Otwórz… Open Recent Otwórz ostatnie Clear Recent Files Wyczyść listę ostatnich plików diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8f7695a04a..ee967c5f5f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -23,6 +23,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Automático segue o tamanho do texto do sistema. As páginas sempre são exibidas na resolução total da tela. Identity Identidade Name on new comments Nome nos novos comentários + Hide hover previews for highlights without comment text Ocultar as prévias ao passar o ponteiro em destaques sem texto de comentário Open… Abrir… Open Recent Abrir recentes Clear Recent Files Limpar arquivos recentes diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8d14595bed..6d1b5639a0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -22,6 +22,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. В режиме «Авто» используется размер текста из настроек системы. Страницы всегда отображаются в полном разрешении экрана. Identity Идентификация Name on new comments Имя в новых комментариях + Hide hover previews for highlights without comment text Скрывать всплывающий просмотр для выделений без текста комментария Open… Открыть… Open Recent Открыть недавние Clear Recent Files Очистить недавние файлы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 6c33abc668..61e2637a77 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1121,6 +1121,7 @@ Name Ime Name (A–Z) Ime (A–Ž) Name on new comments Ime na novih komentarjih + Hide hover previews for highlights without comment text Skrij predoglede ob lebdenju za označitve brez besedila komentarja Name: Ime: Named Destinations Poimenovani cilji Navigation links Navigacijske povezave diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 9431eb1222..c1f075eedc 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -27,6 +27,7 @@ comment menu Edit సవరించు Auto follows the system's text size. Pages always render at the display's full resolution. ఆటో సిస్టమ్ టెక్స్ట్ పరిమాణాన్ని అనుసరిస్తుంది. పేజీలు ఎల్లప్పుడూ డిస్‌ప్లే పూర్తి రిజల్యూషన్‌లో చూపబడతాయి. Identity గుర్తింపు Name on new comments కొత్త వ్యాఖ్యలపై చూపే పేరు + Hide hover previews for highlights without comment text వ్యాఖ్య వచనం లేని హైలైట్‌లకు హోవర్ ప్రివ్యూలను దాచు Open… తెరువు… New blank PDF కొత్త ఖాళీ PDF Create PDF from file… ఫైల్ నుండి PDF సృష్టించు… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 863bf28e31..7c0294e2c1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -15,6 +15,7 @@ Interface language Arayüz dili Identity Kimlik Name on new comments Yeni yorumlardaki ad + Hide hover previews for highlights without comment text Yorum metni olmayan vurgular için üzerine gelme önizlemelerini gizle Open… Aç… New blank PDF Yeni boş PDF Create PDF from file… Dosyadan PDF oluştur… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9b893f0bf9..2e8f576cb5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -32,6 +32,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. Автоматичний режим використовує розмір тексту із системних налаштувань. Сторінки завжди відображаються в повній роздільній здатності екрана. Identity Особисті дані Name on new comments Ім’я в нових коментарях + Hide hover previews for highlights without comment text Приховувати спливний перегляд для виділень без тексту коментаря Open… Відкрити… Open Recent Відкрити нещодавні Pin folder to Home… Закріпити теку на головній… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ded03716c6..dce280a6ab 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -25,6 +25,7 @@ Auto ({percent}%) Tự động ({percent}%) Identity Danh tính Name on new comments Tên trên bình luận mới + Hide hover previews for highlights without comment text Ẩn bản xem trước khi di chuột cho các phần tô sáng không có nội dung nhận xét Open… Mở… Open Recent Mở gần đây Clear Recent Files Xóa tệp gần đây diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 65c78c4a2b..1119c904b0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -63,6 +63,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. “自动”会跟随系统的文字大小。页面始终以显示器的完整分辨率渲染。 Identity 身份 Name on new comments 新注释的作者姓名 + Hide hover previews for highlights without comment text 隐藏无评论文字的高亮的悬停预览 Open… 打开… Open Recent 打开最近的文件 Clear Recent Files 清除最近的文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 38e65dcc4f..bc7c61fdd1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -22,6 +22,7 @@ Auto follows the system's text size. Pages always render at the display's full resolution. 「自動」會跟隨系統的文字大小。頁面一律以螢幕的完整解析度呈現。 Identity 個人資訊 Name on new comments 新註解的作者名稱 + Hide hover previews for highlights without comment text 隱藏沒有註解文字的螢光標示的停留預覽 Open… 開啟… Open Recent 開啟最近的檔案 Clear Recent Files 清除最近的檔案 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index c2ec784ef3..df461fc6e7 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -467,6 +467,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok let label = ui.label(tl!("Name on new comments")); ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(220.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id); }); + ui.add(widgets::checkbox(&mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text"))); ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0))); ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f009e54d1..d101d812e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2277,6 +2277,8 @@ impl PdfCraftApp { }); // Set apart: one more key inside the macro above passes serde_json's recursion limit. v["redact_prefs"] = self.redact_defaults.to_json(); + // Set outside json! above, which is at serde_json's macro recursion limit. + v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); v.to_string() } @@ -2378,6 +2380,9 @@ impl PdfCraftApp { if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) { self.comment_prefs.author = author.chars().take(MAX_AUTHOR_CHARS).collect(); } + if let Some(hide) = v["hide_empty_highlight_hover"].as_bool() { + self.comment_prefs.hide_empty_highlight_hover = hide; + } // Per-tool comment styles (#340): each field goes through its clamping setter, so a // malformed or hostile entry is clamped or ignored rather than trusted. if let Some(styles) = v["comment_styles"].as_array() { @@ -2687,6 +2692,13 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("hide-empty-highlight-hover", _) => { + self.comment_prefs.hide_empty_highlight_hover = match value { + "true" => true, + "false" => false, + _ => return Err("hide-empty-highlight-hover must be true or false".into()), + }; + } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, ("system-title-bar", _) => { self.system_title_bar = match value { diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index b0340d027f..7450052405 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1244,6 +1244,37 @@ fn hovering_a_comment_shows_its_author_and_text() { assert!(h.query_by_label_contains("Check this figure").is_none(), "a dialog covers the page hover"); } +#[test] +fn preference_hides_author_only_highlight_hover_previews() { + // A highlight annotation with author Ada and empty contents over the first page. + let pdf = String::from_utf8_lossy(TEXT_FIXTURE).replace("/Contents 5 0 R /Resources", "/Contents 5 0 R /Annots [8 0 R] /Resources").replace( + "trailer", + "8 0 obj << /Type /Annot /Subtype /Highlight /Rect [50 50 150 120] /C [1 1 0] /T (Ada) /QuadPoints [50 120 150 120 50 50 150 50] >> endobj\ntrailer", + ); + 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("highlight_hover.pdf", None, pdf.into_bytes()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app + }); + h.run_steps(4); + settle(&mut h); + + let p = at(&h, 100.0, 85.0); + // By default, author-only hover is shown. + h.hover_at(p); + h.run_steps(3); + h.get_by_label("Ada"); + + // Enabling preference hides author-only highlight hover. + h.state_mut().set_option("hide-empty-highlight-hover", "true").unwrap(); + h.hover_at(p); + h.run_steps(3); + assert!(h.query_by_label("Ada").is_none(), "author-only hover must be suppressed"); +} + #[test] fn the_squiggly_tool_marks_selected_text() { let mut h = harness(|_| {}); From 980bd900879389850fd1e39112e705f1b10feffa Mon Sep 17 00:00:00 2001 From: Ace40063 Date: Thu, 15 Oct 2026 09:38:06 +0200 Subject: [PATCH 04/19] Print: 4 x 6 in label paper and a Label layout for shipping labels (#1583) * Print: 4 x 6 in label paper and a Label layout for shipping labels Thermal label printers (e.g. VEVOR Y486) could not be used for eBay/carrier labels: the paper list had no 4 x 6 in size, the chosen paper was never sent to CUPS (the queue's default was used), and labels laid out sideways on a Letter/A4 sheet printed small or rotated. - PAPERS gains "4 x 6 in label (101.6 x 152.4 mm)"; cups_media() sends the chosen sheet to the spooler as `-o media=` (Letter, Legal, Tabloid, A3-A5 and w288h432 for the label), so the driver feeds the size the PDF was laid out on. - New Layout::Label: each page is cut to its printed area (a low-resolution render, as Fit Visible does), turned a quarter turn when it is sideways in the direction that makes its text upright (text_reads_up reads the text and form matrices), and fitted uniformly with a small margin. Vector content is kept, so barcodes stay sharp and undistorted. "Turn upside down" flips it 180 degrees. One label per sheet. - Choosing the label paper selects the Label layout, and vice versa. - Strings translated in every bundled catalog. Tests: label geometry (crop, quarter turns, no stretching, margins), cups_media, text direction; print, ui-egui and automation unit tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GFXSksdMaJ3YnheY7Q6sTx * Refresh catalog hashes after merging main --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Alessio Boschi Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 44 +++++----- crates/automation/src/printing.rs | 2 +- crates/engine/src/lib.rs | 14 +++ crates/print/src/lib.rs | 129 +++++++++++++++++++++++++++- crates/print/src/tests.rs | 51 +++++++++++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/kk.tsv | 3 + crates/ui-egui/src/i18n/lt.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/te.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/print_ui.rs | 92 +++++++++++++++++++- 29 files changed, 374 insertions(+), 27 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 5f1e2451c9..bee3f01afc 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 = "3476a5c81ffadc4583688aa6d3bc4456b2b13a16d8daf8cd2b650e764d89ff9d" +sha256 = "66aecafe992d9f1bd6cbfd2152b2aa17af77e6fed93aca5042b5756fae564cb9" # 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 = "c5651b392263a30db0cd6c086e132edcc3ac4fab9a64fd786161bd5917c8d7bb" +sha256 = "407bef213cf599815142fa917e6e0126270979796f9e7a9819c1884d761a32e6" [[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 = "de71e41c22a8154ea46da3ecab69f5b972c8c8f81fa4393e12e449ab598e000f" +sha256 = "052d78606b5a03776e7bbfc277ed1ac08622b91b882e64c039c0283adfb5fb75" [[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 = "cbfa1b7938f52b6d39cec95612bea3bbff5822e2d62f2e1e9871b92bab7efd34" +sha256 = "a4e587d787cb49c8e553b136e83441f7132569011dd5e8aedcd9bab5de5f0ba2" [[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 = "018fd6acbab584c8fa1b49d5950e03e4209f9d073ea83d23bd57c5a114ef35d7" +sha256 = "3101724a510ee4b91cb3ebd44164061b789ed74dc9142c13fd316efd11353ae4" [[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 = "fa5e2462162d403c2c2d341a8d8534945724215dd21f6585ff79797dbdefb47a" +sha256 = "0b349ab77ac8679cce6c13abb7dafbb2a8f19eb523d09573255e28d6e919ec05" [[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 = "d5a3a04bf5cd9ddb2e67d5e4c938508c3f6536806f8a476445c5fea90d275e4c" +sha256 = "3548fa14b5eb68370a81e9cdf17156f40a5b7a10a4d9771b11f9a88cb63b6f3a" [[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 = "1a30e7994b3dfbcca19a6a8a3c7fde8593b4bf7bb546bd2b266b274f3a224c2e" +sha256 = "3441b5c753be2e317aed4bb0e69fdbcd995cc2069977363c55f82b791064043c" [[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 = "3bd44d5996cebc76481d9dd2ded478bbbe2cf5fd24892ad8702a2ed059cbbfa9" +sha256 = "ddb7d9af7883e417fef04e24acef23fea1736c38d362303ab951056ccb3e4473" [[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 = "0dd303b34e6ccbbed8c030e876a696bf555b8d33abac55ca02e415a5a987f067" +sha256 = "8ce577e9405d987b0b78b89c43f9e02f5c03b300c75a22a6cb1f53d84352d1b3" [[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 = "7d59c4324d82ad3be522ce6cff16e5101334b6d816e8d920c529ba2151aa4233" +sha256 = "bbbce91dc8b5ceccaa7c224560a96cf6ba473dc8774cd3a705ac766e8142c203" [[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 = "7f1b964b5ac341c3d6e8b3328a319a2c9d7e6ca358255a155fb3499cb9bb9ee8" +sha256 = "434d3c5d0cde05d62f93ee136bc1cd6e1051e316af354f235a5479fcf23c4c42" [[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 = "c1404eee8a87dde7d0900eb61f945518281e17edae26cc5615dc64e7f3335ee9" +sha256 = "267701ae34f1f0333846d2adcfdd01e2cfd2aa78a45b57cad417ee7b91adcef9" [[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 = "433e5246288f0d27ed10463fe1c33461493a32f2f793684b001546678ade2195" +sha256 = "d38888203419c3fd635a0eb8a38cb6253ee47f3e3b90b388e545661f1f3cdc65" [[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 = "70cd926ae324621d24b6c75519ccab8dfa88f7b6a64a5d52bcf8b115d3ad8e27" +sha256 = "c714b39eafc7a292a4f592255a9f1a82522921c52775ef267e283099aeae7697" [[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 = "fc29daaff6f6377ab0a9e76a64995a0210f894251872d0d9a9a59e721bf542e1" +sha256 = "ddde7f7703c2b906256cb5f58bc9b841dd6351deee6cd24d35349c6e548181de" [[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 = "cfa46745d42d4d4e14f6b3d941d83006129ae331e47a87a3ef673087ad7cb292" +sha256 = "b7bbccc4562d55cb71276ee408fe4106172c03874b4f7c106ede65bcbcd40082" [[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 = "7ee8a57e49498a4a062d04d995a1129034c8e006defaf927460cf5dcf3c19e0e" +sha256 = "89875f19640a31f0e577d68f37a7652ebf28a2a0b9d646a9be2ee6d933839eb2" [[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 = "398c94f07f44ba96f803e3ac64faf606562c5c9a5dc02765c1ed10a648c7251e" +sha256 = "9aaa392d37a93f93e354082c8b677d558df5df1d92b46951ec7a587f8fc93776" [[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 = "7affbbad51af9b4fd54aec7fb118500a81f145eea79070a61e645f989449560b" +sha256 = "6568a24e04e407c75840d8119e55411431f4a6f774b3565ff9ee7d7c79a75e00" [[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 = "1ea1b1ebc84ad7f84efafd6777469259709dc9f4c9ed93147f2522433d42ff5c" +sha256 = "78c0b206ca69f255d80abd18f32a8eeaac397660928256cfed568d897cd9554c" [[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 = "7a4558dc2e1fdbfc254b823839db49d05c6113fdf3414d250895b3063d916bba" +sha256 = "68722b7edd99436f46e786536ca11f03ce4131748e720f460bae9a621252ebcd" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9ecf7d3d1f..19cd48ef43 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -136,7 +136,7 @@ impl Automation { } else { None }; - let settings = print::Settings { pages, paper, orientation, layout, content, raster }; + let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8264da23c2..775c82e2a3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4297,6 +4297,20 @@ impl Session { pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 0de9f4fc37..802d9595b6 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -179,6 +179,11 @@ pub enum Layout { overlap: f64, cut_marks: bool, }, + /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads + /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. + Label { + flip: bool, + }, } impl Default for Layout { @@ -228,6 +233,10 @@ pub struct Settings { pub orientation: Orientation, pub layout: Layout, pub content: Content, + /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its + /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a + /// missing entry keeps the whole page. + pub crops: Vec>, /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this /// resolution before it is placed on the paper. The engine does the rendering; `impose` is /// then given the image-only document. @@ -249,6 +258,12 @@ impl Default for Raster { } } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub rect: [f64; 4], + pub text_up: bool, +} + impl Default for Settings { fn default() -> Self { Settings { @@ -258,12 +273,13 @@ impl Default for Settings { layout: Layout::default(), content: Content::default(), raster: None, + crops: Vec::new(), } } } /// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 19] = [ +pub const PAPERS: [(&str, (f64, f64)); 20] = [ ("US Letter", (612.0, 792.0)), ("US Legal", (612.0, 1008.0)), ("Tabloid", (792.0, 1224.0)), @@ -283,8 +299,91 @@ pub const PAPERS: [(&str, (f64, f64)); 19] = [ ("A2", (1190.55, 1683.78)), ("A1", (1683.78, 2383.94)), ("A0", (2383.94, 3370.39)), + ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), ]; +/// `PAPERS` index of the 4 × 6 in shipping label. +pub const LABEL_PAPER: usize = 19; + +/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out +/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label). +pub fn cups_media(index: usize) -> Option<&'static str> { + match PAPERS.get(index)?.0 { + "US Letter" => Some("Letter"), + "US Legal" => Some("Legal"), + "Tabloid" => Some("Tabloid"), + "A3" => Some("A3"), + "A4" => Some("A4"), + "A5" => Some("A5"), + _ if index == LABEL_PAPER => Some("w288h432"), + _ => None, + } +} + +/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid +/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects +/// are followed a few levels deep. +pub fn text_reads_up(doc: &Document, index: usize) -> bool { + let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; + let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut score = 0i64; + text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score); + score > 0 +} + +fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) { + let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); + for op in pdfcraft_content::parse(data).ops { + match op.op.as_slice() { + // Bounded like the renderer's graphics-state stack: deeper saves are ignored. + b"q" if stack.len() < 256 => stack.push(ctm), + b"Q" => ctm = stack.pop().unwrap_or(base), + b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), + b"BT" => tm = Matrix::IDENTITY, + b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), + b"Tj" | b"TJ" | b"'" | b"\"" => { + let [a, b, ..] = tm.then(&ctm).0; + *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); + } + b"Do" if depth < 4 => { + let Some(name) = op.name(0) else { continue }; + let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; + let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; + if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { + continue; + } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + if let Ok(body) = form.decoded() { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score); + } + } + _ => {} + } + } +} + +/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed +/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. +pub const LABEL_MARGIN: f64 = 8.0; + +/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by +/// `quarter` quarter turns counter-clockwise (0–3). +fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { + let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); + let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; + let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); + let [a, b, c, d, e, f] = match quarter % 4 { + 0 => [s, 0.0, 0.0, s, x, y], + 1 => [0.0, s, -s, 0.0, x + pw, y], + 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], + _ => [0.0, -s, s, 0.0, x, y + ph], + }; + // Move the crop's corner to the origin first. + Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } +} + /// Match a displayed PDF page to a standard sheet, independent of orientation. /// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. pub fn matching_paper(size: (f64, f64)) -> Option { @@ -373,6 +472,34 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); } } + Layout::Label { flip } => { + let size = oriented(paper, settings.orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + for &p in &pages { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); + } + } Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); let Some(cells) = cells else { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e878624087..d4b0c8ee1c 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -644,6 +644,57 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +#[test] +fn label_is_cut_turned_upright_and_fitted_without_distortion() { + let label = LABEL_PAPER; + assert_eq!(PAPERS[label].0, "4 x 6 in label (101.6 x 152.4 mm)"); + assert_eq!(PAPERS[label].1, (288.0, 432.0)); + assert_eq!(cups_media(label), Some("w288h432")); + // A sideways label in the lower half of an A4 sheet, text running bottom-to-top. + let rect = [40.0, 30.0, 560.0, 420.0]; + for (text_up, flip, want_text_dir) in [(true, false, (1.0, 0.0)), (false, false, (1.0, 0.0)), (true, true, (-1.0, 0.0))] { + // Text direction on the page: up when text_up, down otherwise. + let page_dir = if text_up { (0.0, 1.0) } else { (0.0, -1.0) }; + let s = Settings { + pages: vec![0], + paper: PAPERS[label].1, + layout: Layout::Label { flip }, + crops: vec![Some(Crop { rect, text_up })], + ..Settings::default() + }; + let sheets = layout(&[(595.0, 842.0)], &s).unwrap(); + assert_eq!(sheets.len(), 1); + assert_eq!(sheets[0].size, (288.0, 432.0)); + let pl = sheets[0].placed[0]; + assert_eq!(pl.clip, rect); + let [a, b, c, d, _, _] = pl.matrix.0; + // A pure quarter turn with one scale: no stretching. + assert!((a.hypot(b) - c.hypot(d)).abs() < 1e-9 && (a * c + b * d).abs() < 1e-9); + let dir = (a * page_dir.0 + c * page_dir.1, b * page_dir.0 + d * page_dir.1); + let n = dir.0.hypot(dir.1); + assert!((dir.0 / n - want_text_dir.0).abs() < 1e-9 && (dir.1 / n - want_text_dir.1).abs() < 1e-9, "text_up={text_up} flip={flip}"); + let corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[2], rect[3]), (rect[0], rect[3])].map(|(x, y)| pl.matrix.apply(x, y)); + let (x0, x1) = corners.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.0), m.1.max(p.0))); + let (y0, y1) = corners.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1))); + assert!(x0 >= LABEL_MARGIN - 1e-6 && x1 <= 288.0 - LABEL_MARGIN + 1e-6 && y0 >= LABEL_MARGIN - 1e-6 && y1 <= 432.0 - LABEL_MARGIN + 1e-6); + // Fills the label's width (the crop's height, 390 pt, becomes it). + assert!((x1 - x0 - (288.0 - 2.0 * LABEL_MARGIN)).abs() < 1e-6); + } + // No crop: the whole page, upright. + let whole = + layout(&[(200.0, 300.0)], &Settings { pages: vec![0], paper: (288.0, 432.0), layout: Layout::Label { flip: false }, ..Settings::default() }) + .unwrap(); + assert_eq!(whole[0].placed[0].clip, [0.0, 0.0, 200.0, 300.0]); + assert_eq!(whole[0].placed[0].matrix.0[1], 0.0); +} + +#[test] +fn text_direction_is_read_from_text_and_form_matrices() { + let doc = fixture(1); + assert!(!text_reads_up(&doc, 0), "upright text"); + assert!(!text_reads_up(&doc, 9), "no such page"); +} + #[test] fn imposed_pages_preserve_large_user_unit_matrices() { let (unit, mode) = (75000.0, SizeMode::Fit); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a65449ebf1..2d2407629c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -836,6 +836,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { close = true; return; }; + if app.print_draft.handling == crate::print_ui::Handling::Label { + app.read_label_crops(id); + } let Some(doc) = app.session.get(id) else { return }; let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 7c2cfab006..d86ab4d666 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2395,6 +2395,9 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Turn upside down (180°) اقلب رأسًا على عقب (180°) + 4 x 6 in label (101.6 x 152.4 mm) ملصق 4 × 6 بوصة (101.6 × 152.4 مم) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. يقص كل صفحة إلى المنطقة المطبوعة، ويديرها لتصبح مستقيمة ويلائمها مع الورقة دون تمديد. ملصق واحد لكل ورقة. Find untagged content العثور على محتوى غير مُعلَّم No untagged content was found. لم يتم العثور على محتوى غير معلَّم. Found {n} untagged content problems. تم العثور على {n} مشكلة في محتوى غير معلَّم. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6fc9ddb7d8..3985060986 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2337,6 +2337,9 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Turn upside down (180°) Обърни наопаки (180°) + 4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист. Find untagged content Намиране на немаркирано съдържание No untagged content was found. Не е намерено немаркирано съдържание. Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1571b5ab83..bd18db1d94 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2448,6 +2448,9 @@ 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 + Turn upside down (180°) Otočit vzhůru nohama (180°) + 4 x 6 in label (101.6 x 152.4 mm) Štítek 4 × 6 in (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ořízne každou stránku na potištěnou oblast, otočí ji svisle a přizpůsobí listu bez deformace. Jeden štítek na list. Find untagged content Najít neoznačený obsah No untagged content was found. Nebyl nalezen žádný neoznačený obsah. Found {n} untagged content problems. Nalezeno problémů s neoznačeným obsahem: {n}. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9d6ad17e55..eaf011aa67 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2375,6 +2375,9 @@ 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 + Turn upside down (180°) Auf den Kopf drehen (180°) + 4 x 6 in label (101.6 x 152.4 mm) Etikett 4 × 6 Zoll (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Schneidet jede Seite auf ihren bedruckten Bereich zu, richtet sie auf und passt sie ohne Verzerrung an das Blatt an. Ein Etikett pro Blatt. Find untagged content Nicht markierten Inhalt suchen No untagged content was found. Es wurde kein unmarkierter Inhalt gefunden. Found {n} untagged content problems. {n} Probleme mit unmarkiertem Inhalt gefunden. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7b6a32fe89..511582357e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2384,6 +2384,9 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Turn upside down (180°) Αναποδογύρισμα (180°) + 4 x 6 in label (101.6 x 152.4 mm) Ετικέτα 4 × 6 in (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Περικόπτει κάθε σελίδα στην τυπωμένη περιοχή της, την ισιώνει και την προσαρμόζει στο φύλλο χωρίς παραμόρφωση. Μία ετικέτα ανά φύλλο. Find untagged content Εύρεση μη επισημασμένου περιεχομένου No untagged content was found. Δεν βρέθηκε μη επισημασμένο περιεχόμενο. Found {n} untagged content problems. Βρέθηκαν {n} προβλήματα μη επισημασμένου περιεχομένου. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dc36934c01..fc8b85013b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2514,6 +2514,9 @@ 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 + Turn upside down (180°) Girar boca abajo (180°) + 4 x 6 in label (101.6 x 152.4 mm) Etiqueta 4 × 6 in (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Recorta cada página a su área impresa, la endereza y la ajusta a la hoja sin deformarla. Una etiqueta por hoja. Find untagged content Buscar contenido sin etiquetar No untagged content was found. No se encontró contenido sin etiquetar. Found {n} untagged content problems. Se encontraron {n} problemas de contenido sin etiquetar. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 437654d083..c63e4f69c6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2374,6 +2374,9 @@ 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 + Turn upside down (180°) Retourner (180°) + 4 x 6 in label (101.6 x 152.4 mm) Étiquette 4 × 6 po (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Rogne chaque page à sa zone imprimée, la redresse et l’ajuste à la feuille sans la déformer. Une étiquette par feuille. Find untagged content Rechercher le contenu non balisé No untagged content was found. Aucun contenu non balisé n’a été trouvé. Found {n} untagged content problems. {n} problèmes de contenu non balisé trouvés. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c058424eb8..416479ae4d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2373,6 +2373,9 @@ 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 + Turn upside down (180°) Fejjel lefelé fordítás (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 hüvelykes címke (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Minden oldalt a nyomtatott területére vág, függőlegesre fordítja, és torzítás nélkül a laphoz igazítja. Laponként egy címke. Find untagged content Címkézetlen tartalom keresése No untagged content was found. Nem található címkézetlen tartalom. Found {n} untagged content problems. {n} címkézetlen tartalmi probléma található. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4f750fd56d..2b39ba31e2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2383,6 +2383,9 @@ 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 + Turn upside down (180°) Capovolgi (ruota di 180°) + 4 x 6 in label (101.6 x 152.4 mm) Etichetta 4×6 - 101,6 × 152,4 mm + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ritaglia l'area stampata di ogni pagina, la raddrizza e la adatta al foglio senza deformarla. Un'etichetta per foglio. Find untagged content Trova contenuto senza tag No untagged content was found. Non è stato trovato contenuto senza tag. Found {n} untagged content problems. Trovati {n} problemi di contenuto senza tag. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 36b586f1a0..66d3134551 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2374,6 +2374,9 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Turn upside down (180°) 上下反転 (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 インチ ラベル (101.6 × 152.4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 各ページを印刷領域で切り抜き、正しい向きに回転して、伸縮させずに用紙に合わせます。1 枚に 1 ラベル。 Find untagged content タグなしのコンテンツを検索 No untagged content was found. タグなしのコンテンツは見つかりませんでした。 Found {n} untagged content problems. タグなしのコンテンツの問題が {n} 件見つかりました。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 514dab8cfe..0807d286a1 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2372,6 +2372,9 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Turn upside down (180°) Төңкеру (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 дюйм жапсырма (101,6 × 152,4 мм) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Әр бетті басылған аймағына дейін қиып, тік бұрып, созбай параққа сыйғызады. Әр параққа бір жапсырма. Find untagged content Тегтелмеген мазмұнды табу No untagged content was found. Тегтелмеген мазмұн табылмады. Found {n} untagged content problems. Тегтелмеген мазмұнға қатысты {n} мәселе табылды. diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index b5068417f1..7294ac6792 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2618,6 +2618,9 @@ 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 + Turn upside down (180°) Apversti aukštyn kojomis (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 col. etiketė (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Apkerpa kiekvieną puslapį iki spausdinamos srities, pasuka tiesiai ir pritaiko lapui neiškraipydama. Viena etiketė lape. Find untagged content Rasti nepažymėtą turinį No untagged content was found. Nepažymėto turinio nerasta. Found {n} untagged content problems. Rasta nepažymėto turinio problemų: {n}. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ce9f9bb3ca..07d1afcd04 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2354,6 +2354,9 @@ 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 + Turn upside down (180°) Obróć do góry nogami (180°) + 4 x 6 in label (101.6 x 152.4 mm) Etykieta 4 × 6 cali (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Przycina każdą stronę do zadrukowanego obszaru, obraca ją pionowo i dopasowuje do arkusza bez rozciągania. Jedna etykieta na arkusz. Find untagged content Znajdź nieoznaczoną zawartość No untagged content was found. Nie znaleziono nieoznaczonej zawartości. Found {n} untagged content problems. Znaleziono problemów z nieoznaczoną zawartością: {n}. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ee967c5f5f..6f231083e5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2388,6 +2388,9 @@ 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 + Turn upside down (180°) Virar de cabeça para baixo (180°) + 4 x 6 in label (101.6 x 152.4 mm) Etiqueta 4 × 6 pol (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Recorta cada página na área impressa, deixa-a na vertical e ajusta-a à folha sem distorcer. Uma etiqueta por folha. Find untagged content Encontrar conteúdo sem marcação No untagged content was found. Nenhum conteúdo sem marcação foi encontrado. Found {n} untagged content problems. Foram encontrados {n} problemas de conteúdo sem marcação. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6d1b5639a0..e829baa608 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2287,6 +2287,9 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Turn upside down (180°) Перевернуть (180°) + 4 x 6 in label (101.6 x 152.4 mm) Этикетка 4 × 6 дюйма (101,6 × 152,4 мм) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрезает каждую страницу по напечатанной области, разворачивает её прямо и вписывает в лист без искажений. Одна этикетка на лист. Find untagged content Найти нетегированный контент No untagged content was found. Нетегированный контент не найден. Found {n} untagged content problems. Найдено проблем с нетегированным контентом: {n}. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 61e2637a77..96c41876e0 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2379,6 +2379,9 @@ 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 + Turn upside down (180°) Obrni na glavo (180°) + 4 x 6 in label (101.6 x 152.4 mm) Nalepka 4 × 6 in (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Vsako stran obreže na natisnjeno območje, jo poravna pokonci in prilagodi listu brez raztegovanja. Ena nalepka na list. Find untagged content Poišči neoznačeno vsebino No untagged content was found. Neoznačene vsebine ni bilo mogoče najti. Found {n} untagged content problems. Najdenih je {n} težav z neoznačeno vsebino. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index c1f075eedc..a7b63f80fb 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2287,6 +2287,9 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Turn upside down (180°) తలకిందులుగా తిప్పు (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 అం. లేబుల్ (101.6 × 152.4 మి.మీ.) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. ప్రతి పేజీని ముద్రిత ప్రాంతానికి కత్తిరించి, నిటారుగా తిప్పి, సాగదీయకుండా షీట్‌కు సరిపెడుతుంది. ఒక్కో షీట్‌కు ఒక లేబుల్. Find untagged content ట్యాగ్ చేయని కంటెంట్‌ను కనుగొను No untagged content was found. ట్యాగ్ చేయని కంటెంట్ కనుగొనబడలేదు. Found {n} untagged content problems. ట్యాగ్ చేయని కంటెంట్‌లో {n} సమస్యలు కనుగొనబడ్డాయి. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7c0294e2c1..35355b935b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2370,6 +2370,9 @@ 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şı + Turn upside down (180°) Baş aşağı çevir (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 inç etiket (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Her sayfayı basılı alanına göre kırpar, dik çevirir ve germeden sayfaya sığdırır. Sayfa başına bir etiket. Find untagged content Etiketlenmemiş içeriği bul No untagged content was found. Etiketlenmemiş içerik bulunamadı. Found {n} untagged content problems. {n} etiketlenmemiş içerik sorunu bulundu. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2e8f576cb5..6e079a938b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2396,6 +2396,9 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Turn upside down (180°) Перевернути (180°) + 4 x 6 in label (101.6 x 152.4 mm) Етикетка 4 × 6 дюйма (101,6 × 152,4 мм) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрізає кожну сторінку за надрукованою областю, розвертає її прямо та вписує в аркуш без спотворень. Одна етикетка на аркуш. Find untagged content Знайти нетегований вміст No untagged content was found. Нетегованого вмісту не знайдено. Found {n} untagged content problems. Знайдено проблем із нетегованим вмістом: {n}. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dce280a6ab..1cc3b3e630 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2603,6 +2603,9 @@ key Space Space Restore Khôi phục Use system title bar Dùng thanh tiêu đề hệ thống Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Hiện thanh tiêu đề đơn giản cho các nút cửa sổ thay vì dải tab. GNOME không vẽ thanh tiêu đề cho ứng dụng, nên thanh này không theo chủ đề của nó. + Turn upside down (180°) Lật ngược (180°) + 4 x 6 in label (101.6 x 152.4 mm) Nhãn 4 × 6 in (101,6 × 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Cắt mỗi trang theo vùng được in, xoay thẳng và vừa với tờ giấy mà không kéo giãn. Mỗi tờ một nhãn. Find untagged content Tìm nội dung chưa được gắn thẻ No untagged content was found. Không tìm thấy nội dung nào chưa được gắn thẻ. Found {n} untagged content problems. Đã tìm thấy {n} vấn đề về nội dung chưa được gắn thẻ. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1119c904b0..c1b438ada4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2338,6 +2338,9 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Turn upside down (180°) 上下颠倒 (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 英寸标签 (101.6 × 152.4 毫米) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 将每页裁切到其打印区域,转正后不拉伸地适合纸张。每张纸一个标签。 Find untagged content 查找未标记的内容 No untagged content was found. 未找到未标记的内容。 Found {n} untagged content problems. 发现 {n} 个未标记内容问题。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bc7c61fdd1..6752ffdcae 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2250,6 +2250,9 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Turn upside down (180°) 上下顛倒 (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 英吋標籤 (101.6 × 152.4 公釐) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 將每頁裁切至其列印區域,轉正後不拉伸地符合紙張。每張紙一個標籤。 Find untagged content 尋找未標記的內容 No untagged content was found. 找不到未標記的內容。 Found {n} untagged content problems. 發現 {n} 個未標記內容問題。 diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index e521a3d6b6..4e0f065885 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -14,6 +14,9 @@ const PREVIEW_W: f32 = 320.0; const PREVIEW_H: f32 = 380.0; const PREVIEW_INSET: f32 = 12.0; pub(crate) const PREVIEW_BOX: (f32, f32) = (PREVIEW_W - 2.0 * PREVIEW_INSET, PREVIEW_H - 2.0 * PREVIEW_INSET); +/// Label printing reads the printed area of at most this many pages (each is a quick render on +/// the UI thread); further pages print whole. +pub(crate) const LABEL_CROP_PAGES: usize = 50; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -30,6 +33,8 @@ pub enum Handling { Poster, Multiple, Booklet, + /// Shipping label: cut to the printed area, turned upright, one per sheet. + Label, } #[derive(Clone, Debug, PartialEq)] @@ -81,6 +86,13 @@ pub struct PrintDraft { pub show_driver_options: bool, /// Why the printer's preferences window didn't open (Windows). pub driver_error: Option, + /// Label: turn a further 180°. + pub label_flip: bool, + /// Label: each page's printed area and text direction, read when Label is chosen + /// ([`PdfCraftApp::read_label_crops`]); empty until then. + pub crops: Vec>, + /// The pages `crops` were read for, so a changed page selection reads them again. + pub crop_pages: Option>, } impl Default for PrintDraft { @@ -123,6 +135,9 @@ impl Default for PrintDraft { driver_choices: Default::default(), show_driver_options: false, driver_error: None, + label_flip: false, + crops: Vec::new(), + crop_pages: None, } } } @@ -160,6 +175,7 @@ impl PrintDraft { }, Handling::Booklet => Layout::Booklet { subset: self.booklet_subset, binding: self.binding }, Handling::Poster => Layout::Poster { scale: self.poster_scale, overlap: self.overlap, cut_marks: self.cut_marks }, + Handling::Label => Layout::Label { flip: self.label_flip }, }; Ok(print::Settings { pages, @@ -168,6 +184,7 @@ impl PrintDraft { layout, content: self.content, raster: self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }), + crops: self.crops.clone(), }) } @@ -184,7 +201,11 @@ impl PrintDraft { options: match (&self.printer, &self.driver_options) { (Some(p), Some((read_for, _))) if p == read_for => self.driver_choices.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), _ => Vec::new(), - }, + } + .into_iter() + // The sheet the PDF was laid out on, not the queue's default paper. + .chain(print::cups_media(self.paper).map(|m| ("media".to_string(), m.to_string()))) + .collect(), } } } @@ -292,6 +313,8 @@ impl PdfCraftApp { (true, other) => other, }; self.print_draft = PrintDraft { + crops: Vec::new(), + crop_pages: None, printers, printers_loading, printer_picked: false, @@ -306,6 +329,50 @@ impl PdfCraftApp { self.dialog = Some(crate::Dialog::Print); } + /// Label printing: find each printed page's printed area (a quick low-resolution render, as + /// Fit Visible does) and which way its text runs. Only the pages being printed are read, at + /// most [`LABEL_CROP_PAGES`] of them (the rest print whole); read again when they change. + pub(crate) fn read_label_crops(&mut self, id: pdfcraft_engine::DocId) { + let Some(doc) = self.session.get(id) else { return }; + let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let mut pages = self.print_draft.settings(doc.info.pages.len(), &labels).map(|s| s.pages).unwrap_or_default(); + pages.sort_unstable(); + pages.dedup(); + pages.truncate(LABEL_CROP_PAGES); + if self.print_draft.crop_pages.as_ref() == Some(&pages) { + return; + } + let up = self.session.print_text_up(id, &pages); + let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + self.print_draft.crops = doc + .info + .pages + .iter() + .enumerate() + .map(|(page, p)| { + let (w, h) = (f64::from(p.width), f64::from(p.height)); + if pages.binary_search(&page).is_err() || w <= 0.0 || h <= 0.0 { + return None; + } + let out = r.render(pdfcraft_render::RenderRequest { + page, + kind: pdfcraft_render::RequestKind::Pixels, + tile: None, + scale: (1000.0 / w.max(h) as f32).clamp(0.1, 4.0), + tag: 0, + preview: false, + }); + let [u0, v0, u1, v1] = crate::zoom_snap::ink_bounds(out.width as usize, out.height as usize, &out.rgba)?; + // Image rows run down from the top; display space runs up. Half a millimetre of slack. + let pad = 1.5; + let rect = [f64::from(u0) * w - pad, (1.0 - f64::from(v1)) * h - pad, f64::from(u1) * w + pad, (1.0 - f64::from(v0)) * h + pad]; + Some(print::Crop { rect, text_up: up.get(page).copied().unwrap_or(false) }) + }) + .collect(); + self.print_draft.crop_pages = Some(pages); + } + #[cfg(not(target_arch = "wasm32"))] fn list_printers_in_background(&mut self, source: PrinterSource) { // A list already being read is as fresh as a new one would be. @@ -838,11 +905,17 @@ pub(crate) fn body( ui.end_row(); ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); - egui::ComboBox::from_id_salt("paper").selected_text(paper_name).width(260.0).show_ui(ui, |ui| { + let before = d.paper; + egui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { for (i, (name, _)) in PAPERS.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, *name); + ui.selectable_value(&mut d.paper, i, tl!(name)); } }); + // Label stock gets the label layout (cut, turned upright, fitted). + if d.paper != before && d.paper == print::LABEL_PAPER { + d.handling = Handling::Label; + d.sheet = 0; + } ui.end_row(); }); // Windows says which drivers can't print two-sided as they're asked; they keep their @@ -914,10 +987,15 @@ pub(crate) fn body( (Handling::Poster, tl!("Poster")), (Handling::Multiple, tl!("Multiple")), (Handling::Booklet, tl!("Booklet")), + (Handling::Label, tl!("Label")), ] { if widgets::mode_tab(ui, label, d.handling == h).clicked() { d.handling = h; d.sheet = 0; + // A label goes on label stock. + if h == Handling::Label { + d.paper = print::LABEL_PAPER; + } } } }); @@ -937,6 +1015,14 @@ pub(crate) fn body( ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")); }); } + Handling::Label => { + ui.add(widgets::checkbox(&mut d.label_flip, tl!("Turn upside down (180°)"))); + ui.label( + egui::RichText::new(tl!("Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet.")) + .small() + .color(t.text_muted), + ); + } Handling::Poster => { ui.horizontal(|ui| { ui.label(tl!("Tile scale:")); From b7976ef40ef7c7c980f95e1a7d86c0147eee3ab7 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Thu, 15 Oct 2026 09:38:35 +0200 Subject: [PATCH 05/19] M12: show structure tag content (#1496) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/a11y/src/headings.rs | 2 +- crates/a11y/src/lib.rs | 2 +- crates/a11y/src/tags.rs | 60 +++++++++++++++++++++++++++ crates/a11y/src/tests.rs | 19 +++++++++ crates/automation/src/a11y.rs | 10 +++++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 4 ++ crates/engine/src/lib.rs | 5 +++ crates/ui-egui/src/panels.rs | 3 +- crates/ui-egui/src/tags_ui.rs | 43 ++++++++++++++++++- crates/ui-egui/tests/accessibility.rs | 1 + parity/acrobat-features.toml | 10 ++--- 12 files changed, 150 insertions(+), 10 deletions(-) diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 96f99b9a9f..c7e911fde2 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -178,7 +178,7 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict { /// than an em from it, starts a new word (the em is the font size times |`Tz`|; see [`new_word`]). /// Text-positioning operators alone don't break words: producers that place every glyph with its /// own `Td` (Chromium) would otherwise read as "C h a p t e r". -fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { +pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 77c08e4d66..ff81cc4c97 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -26,7 +26,7 @@ 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}; +pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Category { diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index 926350c3d9..565f6620d1 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -21,6 +21,14 @@ pub struct TagInfo { pub page: Option, } +/// A marked-content reference directly owned by a structure tag. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagContentItem { + pub page: Option, + pub mcid: i64, + pub text: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TagProperty { Type, @@ -140,6 +148,58 @@ pub fn tags(doc: &Document) -> Vec { .collect() } +/// List marked-content references directly owned by an indirect tag. +pub fn tag_content(doc: &Document, object: ObjRef) -> Result, TagError> { + let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) }; + let pages = pdfcraft_model::pages(doc); + let page_index: std::collections::HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let mut marks = Vec::new(); + if let Some(kids) = element.dict.get(b"K") { + direct_marks(doc, kids, element.page, &mut marks); + } + // Each page's content is parsed once, however many of its MCIDs the tag owns. + let mut texts: std::collections::HashMap> = std::collections::HashMap::new(); + Ok(marks + .into_iter() + .map(|(page, mcid)| { + let page = page.and_then(|p| page_index.get(&p).copied()); + let text = page + .and_then(|i| { + let page_texts = match texts.entry(i) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(pages.get(i).map(|p| crate::headings::mcid_texts(doc, &p.dict)).unwrap_or_default()) + } + }; + page_texts.get(&mcid).cloned() + }) + .unwrap_or_default(); + TagContentItem { page, mcid, text } + }) + .collect()) +} + +fn direct_marks(doc: &Document, item: &Object, page: Option, out: &mut Vec<(Option, i64)>) { + match item { + Object::Int(mcid) => out.push((page, *mcid)), + Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)), + Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => { + if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) { + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + Object::Ref(reference) => { + if let Some(dict) = doc.get(*reference).as_dict() + && dict.name(b"Type") == Some(b"MCR") + && let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) + { + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + _ => {} + } +} + /// Change one property of a reachable tag; `None` removes a text property. pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> { if !alt::elements(doc).iter().any(|e| e.object == object) { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a3a14a79b4..fcd681efc9 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -598,6 +598,16 @@ fn figures_are_listed_with_their_alt_text_and_place() { assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox)); } +#[test] +fn tag_content_lists_direct_marked_content() { + let doc = figures_doc(); + let tags = tags(&doc); + let first = tag_content(&doc, tags[1].object).unwrap(); + let second = tag_content(&doc, tags[2].object).unwrap(); + assert_eq!((first[0].page, first[0].mcid, first[0].text.as_str()), (Some(0), 0, "")); + assert_eq!((second[0].page, second[0].mcid, second[0].text.as_str()), (Some(0), 1, "")); +} + #[test] fn sibling_tags_move_in_reading_order_without_changing_content() { let mut doc = figures_doc(); @@ -801,6 +811,15 @@ fn headings_are_found_with_their_levels_titles_and_pages() { ); } +#[test] +fn tag_content_reads_the_text_of_each_marked_content_reference() { + let doc = pdf(&headings_doc(), ""); + let items = |n| tag_content(&doc, ref_(n).unwrap()).unwrap().into_iter().map(|i| (i.page, i.mcid, i.text)).collect::>(); + assert_eq!(items(12), [(Some(0), 0, "Chapter One".to_string())]); + assert_eq!(items(13), [(Some(0), 2, "Sec A".to_string())]); + assert_eq!(items(15), [(Some(1), 1, "AB".to_string())]); +} + /// Helvetica advances (standard 14 metrics, per 1000 em) of the characters the tests below draw. fn helvetica_width(c: char) -> f64 { match c { diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 90d308ea9e..1e8d71bd1c 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -511,6 +511,16 @@ impl Automation { Ok(json!({ "count": count, "tags": tags, "next": next })) } + pub(crate) fn accessibility_tag_content(&self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + let generation = + u16::try_from(a.int("generation")?).map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation".into()))?; + let content = self.doc(a)?.tag_content(pdfcraft_cos::ObjRef { num: object, generation }).map_err(failed)?; + Ok( + json!({ "count": content.len(), "content": content.into_iter().map(|item| json!({ "page": item.page.map(|p| p + 1), "mcid": item.mcid, "text": item.text })).collect::>() }), + ) + } + pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result { let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; if object == 0 { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b32c22bce6..1933c7d0d7 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -797,6 +797,7 @@ impl Automation { "accessibility_figures" => self.accessibility_figures(&a)?, "accessibility_set_alt" => self.accessibility_set_alt(&a)?, "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_tag_content" => self.accessibility_tag_content(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, "accessibility_move_tag" => self.accessibility_move_tag(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f2bb9611db..cd83fd5b42 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1013,6 +1013,10 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("accessibility_tag_content", "List tag content", "List the marked-content references directly owned by one reachable indirect structure tag, including page, MCID and extracted text.") + .ro() + .cmd("a11y.tags") + .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])), t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 775c82e2a3..c85556c236 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -594,6 +594,11 @@ impl Document { self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() } + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language /// (`value` is the language), the title (`value` replaces it; else the current title or the /// file name) and the tab order. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8c39874d92..92071c2493 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -702,7 +702,8 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } RightPanel::Tags => { let tags = tags_state.tags(doc); - let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, tags_state, doc.allows_modification()); + let content = tags_state.content(doc); + let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, &content, tags_state, doc.allows_modification()); if let Some(page) = page { nav = Some(Nav::Page(page)); } diff --git a/crates/ui-egui/src/tags_ui.rs b/crates/ui-egui/src/tags_ui.rs index b72a6520ba..ed019cacb3 100644 --- a/crates/ui-egui/src/tags_ui.rs +++ b/crates/ui-egui/src/tags_ui.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use egui::Ui; use pdfcraft_cos::ObjRef; -use pdfcraft_engine::a11y::{TagInfo, TagPlacement, TagProperty}; +use pdfcraft_engine::a11y::{TagContentItem, TagInfo, TagPlacement, TagProperty}; use pdfcraft_engine::{DocId, Edit}; use crate::{icons, theme::Tokens, widgets}; @@ -20,6 +20,9 @@ pub(crate) struct State { /// The tag list per (document, edit generation): walking the structure tree is too slow /// to repeat every frame on a large tagged PDF. cache: Option<(DocId, u64, Arc>)>, + /// The selected tag's marked content per (document, edit generation, tag): reading it parses + /// page content, so it isn't repeated every frame either. + content_cache: Option<(DocId, u64, ObjRef, Arc>)>, } #[derive(Clone, Debug, Default)] @@ -64,6 +67,21 @@ impl Draft { } impl State { + /// The selected tag's marked content, read again only after an edit or a new selection. + pub(crate) fn content(&mut self, doc: &pdfcraft_engine::Document) -> Arc> { + let Some(object) = self.selected else { return Arc::default() }; + let generation = doc.edit_generation(); + if let Some((id, g, o, content)) = &self.content_cache + && *id == doc.id + && *g == generation + && *o == object + { + return content.clone(); + } + let content = Arc::new(doc.tag_content(object).unwrap_or_default()); + self.content_cache = Some((doc.id, generation, object, content.clone())); + content + } /// The document's tags, walked again only after an edit. pub(crate) fn tags(&mut self, doc: &pdfcraft_engine::Document) -> Arc> { let generation = doc.edit_generation(); @@ -102,7 +120,15 @@ impl State { } /// The chosen page and an edit to apply after the panel releases its document borrow. -pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state: &mut State, can_edit: bool) -> (Option, Option) { +pub(crate) fn panel( + ui: &mut Ui, + t: &Tokens, + doc: DocId, + tags: &[TagInfo], + content: &[TagContentItem], + state: &mut State, + can_edit: bool, +) -> (Option, Option) { if state.doc != Some(doc) { state.doc = Some(doc); state.selected = None; @@ -200,6 +226,19 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state .on_hover_text(tl!("Remove this leaf tag from reading order and mark its direct content as an artifact")) .clicked(); }); + ui.separator(); + for item in content { + let page_label = item.page.map(|p| crate::i18n::fmt(tl!("Go to page {p}"), &[("p", &(p + 1).to_string())])); + let label = match (&page_label, item.text.trim()) { + (Some(page), text) if !text.is_empty() => format!("MCID {} · {page} · {text}", item.mcid), + (Some(page), _) => format!("MCID {} · {page}", item.mcid), + (_, text) if !text.is_empty() => format!("MCID {} · {text}", item.mcid), + _ => format!("MCID {}", item.mcid), + }; + if ui.button(label).clicked() { + page = item.page; + } + } ( page, if save { diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index a37098be80..7d13df6adc 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -327,6 +327,7 @@ fn tags_panel_edits_a_tag_property_and_undoes_it() { h.run_steps(2); h.get_by_label("Figure · 7").click(); h.run_steps(2); + h.get_by_label("MCID 0 · Go to page 1"); let title = h.get_by_role_and_label(Role::TextInput, "Title"); title.focus(); title.type_text("First figure"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 779c81f872..57159d7575 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8960,11 +8960,11 @@ title = "Content panel" area = "M" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.tags"] +tools = ["accessibility_tag_content"] +evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"] +notes = "Selecting a tag in the Tags panel lists its direct marked-content references with MCID, page and extracted text, and selecting an item navigates to the page. The content tree is presented within the Tags panel; editing content objects and arbitrary inline structure nodes remain planned." [[feature]] id = "a11y.create-tag-from-selection" From 10ad9b46e59880dfdb1ea80ce8f87b04230665f7 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:42:56 +0200 Subject: [PATCH 06/19] Keep added text selectable over form fields (#1528) * Keep added text selectable over form fields Generated with Codex for @FredApps. * Fix the content.rs test harness merge: harness_bytes and harness_with_step_dt share one builder Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/content_ui.rs | 16 ++++ crates/ui-egui/tests/content.rs | 122 ++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1d0f71c013..9be5842b27 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2892,7 +2892,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { (o.consumed || field_tool.is_some(), false) } else { use crate::forms_ui::FieldPointer; - let field = if tool == QuickTool::Select && !comments::select_grabs(ui, &pcx, view) { + let field = if tool == QuickTool::Select + && !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view)) + && !comments::select_grabs(ui, &pcx, view) + { crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view) } else { FieldPointer::Away diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 58b35de9f0..a2b62e45a7 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -279,6 +279,22 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { } } +/// Added text and images keep pointer ownership over a form field while editing content. +pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { + let cv = &view.content; + if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) { + return true; + } + let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else { + return false; + }; + on_page(added, page).into_iter().any(|(index, item)| { + let rect = screen_rect(xf, info, page, item.content.rect()); + rect.expand(2.0).contains(p) + || cv.selected == Some((page, index)) && handles(rect).into_iter().any(|(corner, _, _)| corner.distance(p) <= 6.0) + }) +} + /// Pointer input on one page in Edit mode (`adding_text` with the Text tool). `true` when it /// used the input. #[allow(clippy::too_many_arguments)] diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index 3ad4851abb..b656593c43 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -7,11 +7,19 @@ use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { - harness_bytes(include_bytes!("data/form.pdf").to_vec()) + harness_with_step_dt(0.25) +} + +fn harness_with_step_dt(step_dt: f32) -> Harness<'static, PdfCraftApp> { + harness_bytes_with_step_dt(include_bytes!("data/form.pdf").to_vec(), step_dt) } fn harness_bytes(bytes: Vec) -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + harness_bytes_with_step_dt(bytes, 0.25) +} + +fn harness_bytes_with_step_dt(bytes: Vec, step_dt: f32) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("form.pdf", None, bytes.clone()).unwrap(); @@ -236,6 +244,116 @@ fn typing_thai_text_keeps_marks_and_commits_to_page() { assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]); } +#[test] +fn finished_text_over_form_field_keeps_selection_and_drag_ownership() { + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let p = at(&h, 60.0, 202.0); + click(&mut h, p); + h.event(egui::Event::Text("Reviewed".into())); + h.run_steps(2); + h.get_by_label("Done adding text").click(); + h.run_steps(3); + assert_eq!(texts(&h), ["Reviewed"]); + let rect = added(&h)[0].content.rect(); + let mid_x = ((rect[0] + rect[2]) / 2.0) as f32; + let mid_y = ((rect[1] + rect[3]) / 2.0) as f32; + assert!((20.0..280.0).contains(&mid_x) && (180.0..200.0).contains(&mid_y), "fixture text must overlap city: {rect:?}"); + let center = at(&h, mid_x, 400.0 - mid_y); + click(&mut h, center); + assert!(h.state().views[0].forms.focus.is_none(), "selecting finished text must not focus the field underneath"); + assert_eq!(h.state().views[0].content.selected, Some((0, 0))); + h.run_steps(60); + let start = at(&h, mid_x, 400.0 - mid_y); + let end = at(&h, mid_x, 400.0 - mid_y - 80.0); + h.drag_at(start); + h.run_steps(1); + for step in 1..=40 { + h.hover_at(start + (end - start) * (step as f32 / 40.0)); + h.run_steps(1); + } + h.drop_at(end); + h.run_steps(4); + let moved = added(&h)[0].content.rect(); + for (actual, expected) in moved.into_iter().zip([rect[0], rect[1] + 80.0, rect[2], rect[3] + 80.0]) { + assert!((actual - expected).abs() < 1.0, "text should move upward: {rect:?} -> {moved:?}"); + } + assert!(h.state().views[0].forms.focus.is_none()); + let bare = at(&h, 270.0, 210.0); + click(&mut h, bare); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); +} + +fn text_over_city() -> Harness<'static, PdfCraftApp> { + let mut h = harness_with_step_dt(1.0 / 60.0); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let p = at(&h, 60.0, 202.0); + click(&mut h, p); + h.event(egui::Event::Text("Reviewed".into())); + h.run_steps(2); + h.get_by_label("Done adding text").click(); + h.run_steps(3); + h +} + +#[test] +fn finished_text_over_field_keeps_move_and_resize_cursors() { + let mut h = text_over_city(); + let rect = added(&h)[0].content.rect(); + let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32); + h.hover_at(center); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move); + // The upper-right handle overlaps the city field; it retains its resize gesture. + let corner = at(&h, rect[2] as f32, (400.0 - rect[3]) as f32); + h.hover_at(corner); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw); + let end = corner + egui::vec2(30.0, -20.0); + h.drag_at(corner); + h.run_steps(1); + for step in 1..=20 { + h.hover_at(corner + (end - corner) * (step as f32 / 20.0)); + h.run_steps(1); + } + h.drop_at(end); + h.run_steps(4); + let resized = added(&h)[0].content.rect(); + assert!(resized[2] > rect[2] + 1.0 && resized[3] > rect[3] + 1.0, "resize must retain ownership: {rect:?} -> {resized:?}"); + assert!(h.state().views[0].forms.focus.is_none()); +} + +#[test] +fn finished_text_over_field_still_opens_its_editor() { + let mut h = text_over_city(); + let rect = added(&h)[0].content.rect(); + let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32); + h.run_steps(60); + h.hover_at(center); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: center, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + } + h.run_steps(2); + assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "double click edits the added text"); + assert!(h.state().views[0].forms.focus.is_none()); +} + +#[test] +fn closing_edit_panel_leaves_form_filling_available() { + let mut h = text_over_city(); + h.state_mut().set_option("left", "closed").unwrap(); + h.run_steps(3); + let p = at(&h, 100.0, 210.0); + click(&mut h, p); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); +} + /// The screen rectangle of an added item's display-space box (y up, from the page's bottom). fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect { let s = h.state(); From 7aac682ef889fd6129a8ae913356a56669b65de0 Mon Sep 17 00:00:00 2001 From: philip-tam Date: Thu, 15 Oct 2026 08:47:20 +0100 Subject: [PATCH 07/19] M7: Cut, Redact, Create Link and Recognize Text in the picture menu; keep the menu open (#1621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M7: add Cut, Redact, Create Link and Recognize Text to the picture menu; keep the menu open The right-click menu of a picture in Edit text & images gains Cut Image, Redact Image (a redaction mark over the picture), Create Link… and Recognize Text… (for the picture's page), with translations in every catalog. The menu was drawn only while the pointer was over the picture, so an item outside it, which is most of a small picture's menu and Delete once the menu reaches below the page, could not be clicked. An open menu is now drawn wherever the pointer is. Co-Authored-By: Claude Sonnet 5.5 * Update the catalog checksums in ATTRIBUTION.toml Co-Authored-By: Claude Sonnet 5.5 * Fix the merged test file and checksums Co-Authored-By: Claude Sonnet 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 44 ++++---- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 11 +- crates/ui-egui/src/content_ui.rs | 58 +++++++++- crates/ui-egui/src/edit_text_ui.rs | 164 ++++++++++++++++++---------- crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/kk.tsv | 4 + crates/ui-egui/src/i18n/lt.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sl.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/tests/viewer.rs | 92 +++++++++++++++- 28 files changed, 373 insertions(+), 85 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index bee3f01afc..12d3e7820d 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 = "66aecafe992d9f1bd6cbfd2152b2aa17af77e6fed93aca5042b5756fae564cb9" +sha256 = "82a6d3b48da75024afd3261e2c2a30d2f8b45da5448b6018ad5b35d489d2c7bb" # 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 = "407bef213cf599815142fa917e6e0126270979796f9e7a9819c1884d761a32e6" +sha256 = "1d8134aad27f74186b0df2f6257ee51b0955a69597de5ca86cf4013ab98f7da9" [[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 = "052d78606b5a03776e7bbfc277ed1ac08622b91b882e64c039c0283adfb5fb75" +sha256 = "1a5a956aabe5746a0768497cb4e6568622a5ab70b8d429f745c33dda977a8fca" [[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 = "a4e587d787cb49c8e553b136e83441f7132569011dd5e8aedcd9bab5de5f0ba2" +sha256 = "5a29fe0c99e7116a2aab2387be553bb95b59ce450afc2546a04949a68dcf4f31" [[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 = "3101724a510ee4b91cb3ebd44164061b789ed74dc9142c13fd316efd11353ae4" +sha256 = "09c3f837fefbe0774e9b21a40d74b93ab3671bb143f141e8cb212c92d91e4733" [[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 = "0b349ab77ac8679cce6c13abb7dafbb2a8f19eb523d09573255e28d6e919ec05" +sha256 = "31ae2ecf486e132ad085b4ec415416263ee1e5e7461ec4dd4e778bf628fefcf9" [[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 = "3548fa14b5eb68370a81e9cdf17156f40a5b7a10a4d9771b11f9a88cb63b6f3a" +sha256 = "fb7f392472e9ffbd7f8492d52b14c8e6bdd28655415869eefbcd1af43178d4c2" [[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 = "3441b5c753be2e317aed4bb0e69fdbcd995cc2069977363c55f82b791064043c" +sha256 = "85576c18721874113d92c8ee477e5a92d43d898f042ec5104bc3ee18336ef2a7" [[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 = "ddb7d9af7883e417fef04e24acef23fea1736c38d362303ab951056ccb3e4473" +sha256 = "1f65ce25add0ffd6dbb9ea25e1bac1f78604265547b1995a7a43e573e303fc7b" [[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 = "8ce577e9405d987b0b78b89c43f9e02f5c03b300c75a22a6cb1f53d84352d1b3" +sha256 = "c1a5a6d120bec8eaa8e60a5eb7f805aaf846f8ae0facf04a5a61da769dc6672c" [[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 = "bbbce91dc8b5ceccaa7c224560a96cf6ba473dc8774cd3a705ac766e8142c203" +sha256 = "06cfe4f2dcdf970aa4269658930885c2536dfc3a1b5391fccc626232fd01b3de" [[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 = "434d3c5d0cde05d62f93ee136bc1cd6e1051e316af354f235a5479fcf23c4c42" +sha256 = "ca6e03bf5a37b39c7e7870e2c52f9c9fd88ec13324296926f0c2853675b155c3" [[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 = "267701ae34f1f0333846d2adcfdd01e2cfd2aa78a45b57cad417ee7b91adcef9" +sha256 = "b0dcd01ae69d702740e6732b77b7ae8d192c4f789be9d3c5128bfa745ce07527" [[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 = "d38888203419c3fd635a0eb8a38cb6253ee47f3e3b90b388e545661f1f3cdc65" +sha256 = "75b42fcb55a4518ba2057a2dfe29cd4b3718c26a0e1a3c83101a19201d9542ce" [[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 = "c714b39eafc7a292a4f592255a9f1a82522921c52775ef267e283099aeae7697" +sha256 = "c9986f1b5a19f8fa3b431229e56bae73715d138e6507b0b7c314483029109585" [[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 = "ddde7f7703c2b906256cb5f58bc9b841dd6351deee6cd24d35349c6e548181de" +sha256 = "ef3156aab103af5bd99efac444a3e085ef50f9a5786c8c4f34aa5f384d2f028e" [[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 = "b7bbccc4562d55cb71276ee408fe4106172c03874b4f7c106ede65bcbcd40082" +sha256 = "13ee22f546e669cbde325343c3c1474c89932bc49342f02da3059047865007f0" [[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 = "89875f19640a31f0e577d68f37a7652ebf28a2a0b9d646a9be2ee6d933839eb2" +sha256 = "79b7ecd15533611107700c32cf9faf6ce1041c41244e5fe82c783d964d0492f7" [[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 = "9aaa392d37a93f93e354082c8b677d558df5df1d92b46951ec7a587f8fc93776" +sha256 = "116784d4457a2cff2018e2652cc537a924f365012409547cbbaac3409c315c22" [[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 = "6568a24e04e407c75840d8119e55411431f4a6f774b3565ff9ee7d7c79a75e00" +sha256 = "581a77647ccd0f002decdb430bcda1a7dfa90d3d2948fe6a12b33251e5edd318" [[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 = "78c0b206ca69f255d80abd18f32a8eeaac397660928256cfed568d897cd9554c" +sha256 = "949a3e933149eaf4fad17fb6711ec38b134858d0c434bdf1c38125bf7ead9855" [[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 = "68722b7edd99436f46e786536ca11f03ce4131748e720f460bae9a621252ebcd" +sha256 = "5ca7e04c805f8ae8bee1b81f6c5e6263dddd1673ef003ec5c90c1b87b4bfd9ba" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/ROADMAP.md b/ROADMAP.md index b23c200bf1..0a54ad02a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. - **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9be5842b27..c2e6e82011 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3699,8 +3699,17 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { match image_action { Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index), Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => app.copy_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => { + app.copy_page_image(page, index); + } Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index), + Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => { + app.views[index].image_selection = None; + app.cut_page_image(page, image); + } + Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page), None => {} } if let Some(why) = refused { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index a2b62e45a7..52f8e3cb43 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -688,12 +688,12 @@ impl crate::PdfCraftApp { } /// Copy Image: image `index` on `page`, at its own resolution, to the clipboard. - pub(crate) fn copy_page_image(&mut self, page: usize, index: usize) { - let Some((_, id)) = self.active_ids() else { return }; + pub(crate) fn copy_page_image(&mut self, page: usize, index: usize) -> bool { + let Some((_, id)) = self.active_ids() else { return false }; if !self.may_copy(id) { - return; + return false; } - let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return }; + let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return false }; let copied = pixels.and_then(|(w, h, rgba)| { #[cfg(not(target_arch = "wasm32"))] if self.system_clipboard { @@ -708,11 +708,57 @@ impl crate::PdfCraftApp { Ok((w, h)) }); match copied { - Ok((w, h)) => self.notify_fmt("The image has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]), - Err(e) => self.notify_fmt("Couldn't copy the image: {e}", &[("e", &e)]), + Ok((w, h)) => { + self.notify_fmt("The image has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]); + true + } + Err(e) => { + self.notify_fmt("Couldn't copy the image: {e}", &[("e", &e)]); + false + } } } + /// The picture's visible box on its page, in user space. + fn page_image_box(&self, page: usize, index: usize) -> Option<[f64; 4]> { + let (_, id) = self.active_ids()?; + let image = self.session.get(id)?.page_images(page).into_iter().nth(index)?; + Some(image.visible_rect.unwrap_or(image.rect)) + } + + /// Cut: copy the picture, and only when that worked take it off the page (one undo step). + pub(crate) fn cut_page_image(&mut self, page: usize, index: usize) { + if self.copy_page_image(page, index) { + self.apply_edit(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete }); + } + } + + /// Redact image: a redaction mark over the whole picture. It is applied, like any mark, from + /// Redact a PDF. + pub(crate) fn redact_page_image(&mut self, page: usize, index: usize) { + let Some(rect) = self.page_image_box(page, index) else { return }; + let author = self.comment_prefs.author.clone(); + let mark = self.redact_prefs.mark(page, vec![pdfcraft_engine::rect_quad(rect)], &author); + if self.apply_edit(mark) { + self.notify_tr("The image is marked for redaction. Apply it with Redact a PDF"); + } + } + + /// Create link: the link properties for a new link over the picture. + pub(crate) fn link_page_image(&mut self, page: usize, index: usize) { + if let Some(rect) = self.page_image_box(page, index) { + self.open_link_props(page, Some(rect), None); + } + } + + /// Recognize text: the Recognize Text dialog, set to this page. + pub fn recognize_text_on_page(&mut self, page: usize) { + self.ocr_draft.pages = crate::ocr_ui::OcrPages::Range; + self.ocr_draft.from = page + 1; + self.ocr_draft.to = page + 1; + self.dialog = Some(crate::Dialog::RecognizeText); + } + /// Click an image field: pick a picture for it. pub fn choose_field_image(&mut self, name: &str) { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d06cb7c8f0..3c0c18a5ec 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -575,6 +575,14 @@ pub(crate) enum ImageAction { Save(usize, usize), Copy(usize, usize), CopyFile(usize, usize), + /// Copy the picture, then remove it from the page. + Cut(usize, usize), + /// Mark the picture for redaction (applied later from Redact a PDF). + Redact(usize, usize), + /// Create a link over the picture. + Link(usize, usize), + /// Open Recognize Text for the picture's page. + Recognize(usize), } fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { @@ -759,6 +767,22 @@ pub(crate) fn image_input( if !allow_interaction { return false; } + // An open menu stays drawn wherever the pointer is: most of its items lie outside the picture + // (a logo is smaller than its menu), and a menu that is not drawn for a frame cannot be clicked. + // Only a menu opened by a right-click on the picture: one opened over text or empty page while + // a picture stays selected is the page's own menu. + let menu_key = resp.id.with("image-menu"); + if resp.secondary_clicked() { + ui.ctx().data_mut(|d| d.insert_temp(menu_key, false)); + } + let mut menu_drawn = false; + if let Some(sel) = selected + && resp.context_menu_opened() + && ui.ctx().data(|d| d.get_temp::(menu_key)).unwrap_or(false) + { + resp.context_menu(|ui| image_menu(ui, page, sel, images, view, action)); + menu_drawn = true; + } let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { return false }; if selected != Some(hit) { @@ -775,65 +799,95 @@ pub(crate) fn image_input( if resp.clicked() || resp.secondary_clicked() { view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None }); } - if selected == Some(hit) { - resp.context_menu(|ui| { - use pdfcraft_engine::ImageEdit as E; - let items: [(&str, Option); 4] = [ - (tl!("Rotate Clockwise"), Some(E::Rotate(1))), - (tl!("Rotate Counterclockwise"), Some(E::Rotate(3))), - (tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })), - (tl!("Flip Vertical"), Some(E::Flip { horizontal: false })), - ]; - for (label, change) in items { - if ui.button(label).clicked() { - view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c }); - ui.close(); - } - } - // Crop: the handles trim instead of resizing until Done. - if ui.button(tl!("Crop Image")).clicked() { - if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) { - s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect))); - } - ui.close(); - } - let cropped = - images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01))); - if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() { - if let Some(im) = images.get(hit) { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) }); - } - ui.close(); - } - let raster = images.get(hit).is_some_and(|image| !image.is_form); - if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() { - *action = Some(ImageAction::Replace(page, hit)); - ui.close(); - } - if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() { - *action = Some(ImageAction::Save(page, hit)); - ui.close(); - } - // Copying also finds the picture inside grouped Form artwork. - if ui.button(tl!("Copy Image")).clicked() { - *action = Some(ImageAction::Copy(page, hit)); - ui.close(); - } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - *action = Some(ImageAction::CopyFile(page, hit)); - ui.close(); - } - ui.separator(); - if ui.button(tl!("Delete")).clicked() { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); - ui.close(); - } - }); + if resp.secondary_clicked() { + ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); + } + if selected == Some(hit) && !menu_drawn { + resp.context_menu(|ui| image_menu(ui, page, hit, images, view, action)); } true } +/// The right-click menu of the picture `hit` (its rotate, flip, replace, copy, cut, redact, link, +/// recognize and delete items). +fn image_menu( + ui: &mut egui::Ui, + page: usize, + hit: usize, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + action: &mut Option, +) { + use pdfcraft_engine::ImageEdit as E; + let items: [(&str, Option); 4] = [ + (tl!("Rotate Clockwise"), Some(E::Rotate(1))), + (tl!("Rotate Counterclockwise"), Some(E::Rotate(3))), + (tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })), + (tl!("Flip Vertical"), Some(E::Flip { horizontal: false })), + ]; + for (label, change) in items { + if ui.button(label).clicked() { + view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c }); + ui.close(); + } + } + // Crop: the handles trim instead of resizing until Done. + if ui.button(tl!("Crop Image")).clicked() { + if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) { + s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect))); + } + ui.close(); + } + let cropped = images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01))); + if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() { + if let Some(im) = images.get(hit) { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) }); + } + ui.close(); + } + let raster = images.get(hit).is_some_and(|image| !image.is_form); + if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() { + *action = Some(ImageAction::Replace(page, hit)); + ui.close(); + } + if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() { + *action = Some(ImageAction::Save(page, hit)); + ui.close(); + } + // Cutting and copying also find the picture inside grouped Form artwork. + if ui.button(tl!("Cut Image")).clicked() { + *action = Some(ImageAction::Cut(page, hit)); + ui.close(); + } + if ui.button(tl!("Copy Image")).clicked() { + *action = Some(ImageAction::Copy(page, hit)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + *action = Some(ImageAction::CopyFile(page, hit)); + ui.close(); + } + ui.separator(); + if ui.button(tl!("Redact Image")).clicked() { + *action = Some(ImageAction::Redact(page, hit)); + ui.close(); + } + if ui.button(tl!("Create Link…")).clicked() { + *action = Some(ImageAction::Link(page, hit)); + ui.close(); + } + if ui.button(tl!("Recognize Text…")).clicked() { + *action = Some(ImageAction::Recognize(page)); + ui.close(); + } + ui.separator(); + if ui.button(tl!("Delete")).clicked() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); + ui.close(); + } +} + /// Open `block` on `page` in the inline editor. The box's geometry and source are read from the /// `lines` handed in, so callers pass the document's current inventory. #[allow(clippy::too_many_arguments)] diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index d86ab4d666..041a1332a0 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2378,6 +2378,10 @@ animation effects Off معطّلة The linked PDF opened, but its named destination was not applied. فُتح ملف PDF المرتبط، لكن لم تُطبَّق وجهته المسماة. The linked PDF opened, but its page destination was out of range. فُتح ملف PDF المرتبط، لكن وجهة الصفحة خارج النطاق. Local cross-PDF links require the desktop app تتطلب الروابط المحلية بين ملفات PDF تطبيق سطح المكتب + Cut Image قص الصورة + Redact Image حجب الصورة + Create Link… إنشاء رابط… + Recognize Text… التعرف على النص… Minimize تصغير Maximize تكبير Restore استعادة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3985060986..5334c39e9b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2320,6 +2320,10 @@ animation effects Off Изключени The linked PDF opened, but its named destination was not applied. Свързаният PDF се отвори, но именуваната му цел не беше приложена. The linked PDF opened, but its page destination was out of range. Свързаният PDF се отвори, но целевата му страница е извън обхвата. Local cross-PDF links require the desktop app Локалните връзки между PDF файлове изискват настолното приложение + Cut Image Изрежи изображението + Redact Image Заличи изображението + Create Link… Създай връзка… + Recognize Text… Разпознаване на текст… Minimize Минимизиране Maximize Максимизиране Restore Възстановяване diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bd18db1d94..9e62f7f348 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2431,6 +2431,10 @@ animation effects Off Vypnuto The linked PDF opened, but its named destination was not applied. Odkazovaný PDF se otevřel, ale jeho pojmenovaný cíl nebyl použit. The linked PDF opened, but its page destination was out of range. Odkazovaný PDF se otevřel, ale cílová stránka je mimo rozsah. Local cross-PDF links require the desktop app Místní odkazy mezi soubory PDF vyžadují desktopovou aplikaci + Cut Image Vyjmout obrázek + Redact Image Začernit obrázek + Create Link… Vytvořit odkaz… + Recognize Text… Rozpoznat text… Minimize Minimalizovat Maximize Maximalizovat Restore Obnovit diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eaf011aa67..29439a186a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2358,6 +2358,10 @@ animation effects Off Aus The linked PDF opened, but its named destination was not applied. Die verknüpfte PDF wurde geöffnet, aber ihr benanntes Ziel wurde nicht angewendet. The linked PDF opened, but its page destination was out of range. Die verknüpfte PDF wurde geöffnet, aber ihr Seitenziel liegt außerhalb des Bereichs. Local cross-PDF links require the desktop app Lokale Verknüpfungen zwischen PDFs erfordern die Desktop-App + Cut Image Bild ausschneiden + Redact Image Bild schwärzen + Create Link… Link erstellen… + Recognize Text… Text erkennen… Minimize Minimieren Maximize Maximieren Restore Wiederherstellen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 511582357e..36a1d99095 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2367,6 +2367,10 @@ animation effects Off Ανενεργά Local cross-PDF links require the desktop app Οι τοπικοί σύνδεσμοι μεταξύ PDF απαιτούν την εφαρμογή υπολογιστή The existing action will be preserved. Η υπάρχουσα ενέργεια θα διατηρηθεί. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Τα σενάρια αυτής της φόρμας είναι απενεργοποιημένα, επειδή ένα από αυτά εκτελούνταν για πολύ ώρα. Ανοίξτε ξανά το έγγραφο για να εκτελεστούν ξανά. + Cut Image Αποκοπή εικόνας + Redact Image Απόκρυψη ευαίσθητου περιεχομένου εικόνας + Create Link… Δημιουργία συνδέσμου… + Recognize Text… Αναγνώριση κειμένου… Minimize Ελαχιστοποίηση Maximize Μεγιστοποίηση Restore Επαναφορά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fc8b85013b..62cbf95fe3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2497,6 +2497,10 @@ animation effects Off Desactivados The linked PDF opened, but its page destination was out of range. El PDF vinculado se abrió, pero su página de destino está fuera de rango. Local cross-PDF links require the desktop app Los enlaces locales entre PDF requieren la aplicación de escritorio The existing action will be preserved. Se conservará la acción existente. + Cut Image Cortar imagen + Redact Image Censurar imagen + Create Link… Crear enlace… + Recognize Text… Reconocer texto… Minimize Minimizar Maximize Maximizar Restore Restaurar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c63e4f69c6..208f5d2c25 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2357,6 +2357,10 @@ animation effects Off Désactivés The linked PDF opened, but its named destination was not applied. Le PDF lié s'est ouvert, mais sa destination nommée n'a pas été appliquée. The linked PDF opened, but its page destination was out of range. Le PDF lié s'est ouvert, mais sa page de destination est hors limites. Local cross-PDF links require the desktop app Les liens locaux entre PDF nécessitent l'application de bureau + Cut Image Couper l'image + Redact Image Biffer l'image + Create Link… Créer un lien… + Recognize Text… Reconnaître le texte… Minimize Réduire Maximize Agrandir Restore Restaurer diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 416479ae4d..fe31b0e7a8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2356,6 +2356,10 @@ animation effects Off Ki The linked PDF opened, but its named destination was not applied. A hivatkozott PDF megnyílt, de a nevesített cél nem lett alkalmazva. The linked PDF opened, but its page destination was out of range. A hivatkozott PDF megnyílt, de a céloldal a tartományon kívül esik. Local cross-PDF links require the desktop app A PDF-ek közötti helyi hivatkozásokhoz az asztali alkalmazás szükséges + Cut Image Kép kivágása + Redact Image Kép kitakarása + Create Link… Hivatkozás létrehozása… + Recognize Text… Szövegfelismerés… Minimize Minimalizálás Maximize Maximalizálás Restore Visszaállítás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2b39ba31e2..0cda1c3232 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2366,6 +2366,10 @@ animation effects Off Disattivati The linked PDF opened, but its named destination was not applied. Il PDF collegato è stato aperto, ma la sua destinazione con nome non è stata applicata. The linked PDF opened, but its page destination was out of range. Il PDF collegato è stato aperto, ma la pagina di destinazione è fuori intervallo. Local cross-PDF links require the desktop app I collegamenti locali tra PDF richiedono l'app desktop + Cut Image Taglia immagine + Redact Image Oscura immagine + Create Link… Crea collegamento… + Recognize Text… Riconosci testo… Minimize Riduci a icona Maximize Ingrandisci Restore Ripristina diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 66d3134551..48bcb8f25d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2357,6 +2357,10 @@ animation effects Off オフ The linked PDF opened, but its named destination was not applied. リンク先の PDF を開きましたが、名前付き移動先は適用されませんでした。 The linked PDF opened, but its page destination was out of range. リンク先の PDF を開きましたが、移動先のページが範囲外です。 Local cross-PDF links require the desktop app PDF 間のローカルリンクにはデスクトップアプリが必要です + Cut Image 画像を切り取り + Redact Image 画像を墨消し + Create Link… リンクを作成… + Recognize Text… テキストを認識… Minimize 最小化 Maximize 最大化 Restore 元のサイズに戻す diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 0807d286a1..13a9f67297 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2355,6 +2355,10 @@ animation effects Off Өшірулі The linked PDF opened, but its named destination was not applied. Байланысты PDF ашылды, бірақ оның аталған мақсаты қолданылмады. The linked PDF opened, but its page destination was out of range. Байланысты PDF ашылды, бірақ оның мақсатты беті ауқымнан тыс. Local cross-PDF links require the desktop app PDF файлдары арасындағы жергілікті сілтемелер үшін жұмыс үстелі қолданбасы қажет + Cut Image Суретті кесу + Redact Image Суретті жасыру + Create Link… Сілтеме жасау… + Recognize Text… Мәтінді тану… Minimize Жию Maximize Жаю Restore Қалпына келтіру diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 7294ac6792..165163d31f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2601,6 +2601,10 @@ key Esc Esc key Home Home key End End key Space Tarpas + Cut Image Iškirpti paveikslėlį + Redact Image Pašalinti konfidencialią informaciją iš paveikslėlio + Create Link… Kurti saitą… + Recognize Text… Atpažinti tekstą… Minimize Sumažinti Maximize Išdidinti Restore Atkurti diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 07d1afcd04..5cc4093606 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2337,6 +2337,10 @@ animation effects Off Wyłączone Local cross-PDF links require the desktop app Lokalne łącza między plikami PDF wymagają aplikacji komputerowej The existing action will be preserved. Istniejąca akcja zostanie zachowana. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Skrypty tego formularza są wyłączone, ponieważ jeden z nich działał zbyt długo. Otwórz dokument ponownie, aby znów je uruchomić. + Cut Image Wytnij obraz + Redact Image Utajnij obraz + Create Link… Utwórz łącze… + Recognize Text… Rozpoznaj tekst… Minimize Zminimalizuj Maximize Zmaksymalizuj Restore Przywróć diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6f231083e5..8756fa94ee 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2371,6 +2371,10 @@ animation effects Off Desativados The linked PDF opened, but its named destination was not applied. O PDF vinculado foi aberto, mas seu destino nomeado não foi aplicado. The linked PDF opened, but its page destination was out of range. O PDF vinculado foi aberto, mas sua página de destino está fora do intervalo. Local cross-PDF links require the desktop app Links locais entre PDFs exigem o aplicativo para desktop + Cut Image Recortar imagem + Redact Image Ocultar dados da imagem + Create Link… Criar link… + Recognize Text… Reconhecer texto… Minimize Minimizar Maximize Maximizar Restore Restaurar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e829baa608..d9fe1a98b4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2270,6 +2270,10 @@ animation effects Off Выкл The linked PDF opened, but its named destination was not applied. Связанный PDF открыт, но его именованное назначение не применено. The linked PDF opened, but its page destination was out of range. Связанный PDF открыт, но страница назначения вне допустимого диапазона. Local cross-PDF links require the desktop app Локальные ссылки между PDF работают только в настольном приложении + Cut Image Вырезать изображение + Redact Image Скрыть изображение + Create Link… Создать ссылку… + Recognize Text… Распознать текст… Minimize Свернуть Maximize Развернуть Restore Восстановить diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 96c41876e0..d59de3df85 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2362,6 +2362,10 @@ animation effects Off Izklopljeni The linked PDF opened, but its named destination was not applied. Povezani PDF se je odprl, vendar njegov poimenovani cilj ni bil uporabljen. The linked PDF opened, but its page destination was out of range. Povezani PDF se je odprl, vendar je ciljna stran zunaj obsega. Local cross-PDF links require the desktop app Lokalne povezave med PDF-ji zahtevajo namizno aplikacijo + Cut Image Izreži sliko + Redact Image Počrni sliko + Create Link… Ustvari povezavo… + Recognize Text… Prepoznaj besedilo… Minimize Pomanjšaj Maximize Povečaj Restore Obnovi diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index a7b63f80fb..ea10d79910 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2270,6 +2270,10 @@ animation effects Off ఆఫ్ Local cross-PDF links require the desktop app PDFల మధ్య స్థానిక లింక్‌లకు డెస్క్‌టాప్ యాప్ అవసరం The existing action will be preserved. ఉన్న చర్య అలాగే ఉంచబడుతుంది. This form's scripts are turned off because one ran too long. Reopen the document to run them again. ఒక స్క్రిప్ట్ చాలా సేపు నడిచినందున ఈ ఫారమ్ స్క్రిప్ట్‌లు ఆపివేయబడ్డాయి. వాటిని మళ్లీ నడపడానికి పత్రాన్ని మళ్లీ తెరవండి. + Cut Image చిత్రాన్ని కత్తిరించు + Redact Image చిత్రాన్ని రెడాక్ట్ చేయి + Create Link… లింక్ సృష్టించు… + Recognize Text… టెక్స్ట్‌ను గుర్తించు… Minimize కనిష్ఠీకరించు Maximize గరిష్ఠీకరించు Restore పునరుద్ధరించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 35355b935b..31fce37c84 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2353,6 +2353,10 @@ animation effects Off Kapalı The linked PDF opened, but its named destination was not applied. Bağlantılı PDF açıldı, ancak adlandırılmış hedefi uygulanmadı. The linked PDF opened, but its page destination was out of range. Bağlantılı PDF açıldı, ancak sayfa hedefi aralık dışında. Local cross-PDF links require the desktop app PDF'ler arası yerel bağlantılar masaüstü uygulamasını gerektirir + Cut Image Resmi Kes + Redact Image Resmi karart + Create Link… Bağlantı oluştur… + Recognize Text… Metni Tanı… Minimize Küçült Maximize Büyüt Restore Geri yükle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6e079a938b..db6e7b0ffc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2379,6 +2379,10 @@ animation effects Off Вимкнено The linked PDF opened, but its named destination was not applied. Пов’язаний PDF відкрито, але його іменоване призначення не застосовано. The linked PDF opened, but its page destination was out of range. Пов’язаний PDF відкрито, але сторінка призначення поза межами діапазону. Local cross-PDF links require the desktop app Локальні посилання між PDF працюють лише в настільному застосунку + Cut Image Вирізати зображення + Redact Image Вилучити вміст зображення + Create Link… Створити посилання… + Recognize Text… Розпізнати текст… Minimize Згорнути Maximize Розгорнути Restore Відновити diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1cc3b3e630..d35f951512 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2609,3 +2609,7 @@ key Space Space Find untagged content Tìm nội dung chưa được gắn thẻ No untagged content was found. Không tìm thấy nội dung nào chưa được gắn thẻ. Found {n} untagged content problems. Đã tìm thấy {n} vấn đề về nội dung chưa được gắn thẻ. + Cut Image Cắt hình ảnh + Redact Image Xóa nội dung nhạy cảm của hình ảnh + Create Link… Tạo liên kết… + Recognize Text… Nhận dạng văn bản… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c1b438ada4..72b0cd30a9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2321,6 +2321,10 @@ animation effects Off 关 The linked PDF opened, but its named destination was not applied. 已打开链接的 PDF,但未应用其命名目标。 The linked PDF opened, but its page destination was out of range. 已打开链接的 PDF,但其页面目标超出范围。 Local cross-PDF links require the desktop app PDF 之间的本地链接需要桌面应用 + Cut Image 剪切图像 + Redact Image 密文图像 + Create Link… 创建链接… + Recognize Text… 识别文本… Minimize 最小化 Maximize 最大化 Restore 还原 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6752ffdcae..6689525548 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2233,6 +2233,10 @@ animation effects Off 關 The linked PDF opened, but its page destination was out of range. 已開啟連結的 PDF,但其頁面目的地超出範圍。 Local cross-PDF links require the desktop app PDF 之間的本機連結需要桌面應用程式 The existing action will be preserved. 將保留現有動作。 + Cut Image 剪下圖片 + Redact Image 遮蔽圖片 + Create Link… 建立連結… + Recognize Text… 辨識文字… Minimize 最小化 Maximize 最大化 Restore 還原 diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 06e22fe3b0..077553c71d 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1297,12 +1297,17 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() { /// A 40 × 30 picture (one red corner) and a harness showing a PDF of it. fn picture_harness() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) { + picture_harness_sized(egui::vec2(1400.0, 900.0)) +} + +/// [`picture_harness`] in a window of this size. +fn picture_harness_sized(size: egui::Vec2) -> (image::RgbaImage, Harness<'static, PdfCraftApp>) { let mut px = image::RgbaImage::from_pixel(40, 30, image::Rgba([10, 200, 30, 255])); px.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); let mut png = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(px.clone()).write_to(&mut png, image::ImageFormat::Png).unwrap(); let pdf = pdfcraft_engine::Session::new().create_from_images(&[("p.png".into(), png.into_inner())]).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("pic.pdf", None, pdf.to_vec()).unwrap(); @@ -1417,6 +1422,91 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() { let png = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png).unwrap().into_rgba8(); assert_eq!(png.into_raw(), vec![255, 0, 0, 255, 0, 0, 255, 255]); } + +/// A picture in Edit text & images, right-clicked so its menu is open (in a tall window, so the +/// whole menu is on screen). +fn picture_menu() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) { + let (px, mut h) = picture_harness_sized(egui::vec2(1400.0, 1500.0)); + h.state_mut().set_option("quick", "edit-text").unwrap(); + h.run_steps(3); + let i = h.state().active.unwrap(); + let at = h.state().views[i].page_screen_rect(0).expect("on screen").center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + (px, h) +} + +fn picture_count(h: &Harness<'static, PdfCraftApp>) -> usize { + let s = h.state(); + s.session.get(s.active_ids().unwrap().1).unwrap().page_images(0).len() +} + +#[test] +fn the_picture_menu_cuts_a_picture_to_the_clipboard_and_removes_it() { + let (px, mut h) = picture_menu(); + assert_eq!(picture_count(&h), 1); + h.get_by_label("Cut Image").click(); + h.run_steps(3); + let (w, hgt, rgba) = h.state().last_copied_image.clone().expect("the image was copied first"); + assert_eq!((w, hgt), (40, 30)); + assert_eq!(rgba, px.into_raw()); + assert_eq!(picture_count(&h), 0, "then taken off the page"); + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(picture_count(&h), 1, "one undo step puts it back"); +} + +#[test] +fn the_picture_menu_marks_a_picture_for_redaction() { + let (_, mut h) = picture_menu(); + let marks = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.active_ids().unwrap().1).unwrap().redaction_marks() + }; + assert_eq!(marks(&h), 0); + h.get_by_label("Redact Image").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + assert_eq!(picture_count(&h), 1, "marked, not yet removed: Redact a PDF applies it"); +} + +#[test] +fn the_picture_menu_opens_link_properties_and_recognize_text_for_the_picture() { + let (_, mut h) = picture_menu(); + h.get_by_label("Create Link…").click(); + h.run_steps(3); + assert!(matches!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::LinkProps))); + let draft = h.state().link_draft.clone().expect("the link draft"); + assert_eq!(draft.page, 0); + assert!(draft.rect[2] > draft.rect[0] && draft.rect[3] > draft.rect[1], "over the picture: {:?}", draft.rect); + + let (_, mut h) = picture_menu(); + h.get_by_label("Recognize Text…").click(); + h.run_steps(3); + assert!(matches!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::RecognizeText))); + let d = &h.state().ocr_draft; + assert_eq!((format!("{:?}", d.pages), d.from, d.to), ("Range".to_string(), 1, 1)); +} + +/// The menu is longer than the picture is tall, so most items lie outside it: the pointer moving +/// onto them must not close the menu (it used to be drawn only while the pointer was over the +/// picture, so Delete and the items below the page could not be clicked). +#[test] +fn every_item_of_the_picture_menu_can_be_clicked_outside_the_picture() { + let (_, mut h) = picture_menu(); + let s = h.state(); + let page = s.views[s.active.unwrap()].page_screen_rect(0).expect("on screen"); + let delete = h.get_by_label("Delete").rect(); + assert!(delete.center().y > page.bottom(), "Delete lies below the page: {delete:?} vs {page:?}"); + assert_eq!(picture_count(&h), 1); + h.get_by_label("Delete").click(); + h.run_steps(3); + assert_eq!(picture_count(&h), 0); +} + #[test] fn mouse_wheel_scrolls_while_selecting_text() { let mut h = harness(); From 57c8de09abb3e7ca3d0e189581c4b4e331e03570 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Thu, 15 Oct 2026 00:47:41 -0700 Subject: [PATCH 08/19] M6: preserve XFDF field text around XML comments (#1647) Join the text nodes in each field value so XML comments and processing instructions do not truncate imported text. Cover data extraction and form import through PDF save/reopen. Co-authored-by: storybored379 Co-authored-by: OpenAI Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/xfdf/README.md | 2 +- crates/xfdf/src/lib.rs | 7 ++++++- crates/xfdf/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ docs/roadmap.md | 7 ++++--- parity/acrobat-features.toml | 4 ++-- 6 files changed, 49 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0a54ad02a6..b9158839a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index fd2852584b..2b179e2007 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -13,7 +13,7 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o circle, polygon, polyline, markup, stamp, caret, ink, attachments, sound, redact) with rect, name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink, default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads - (`inreplyto`); field values nested by name. + (`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions. - **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects. - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d9287101ba..e271364d9d 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -925,7 +925,12 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec = f.children().filter(|c| c.has_tag_name("value")).map(|v| v.text().unwrap_or("").to_string()).collect(); + let vals: Vec = f + .children() + .filter(|c| c.has_tag_name("value")) + // Comments and processing instructions can split a value into several text nodes. + .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) + .collect(); if !vals.is_empty() { out.push((full.clone(), vals)); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index be747d77e1..fbb2fb21a0 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -887,3 +887,38 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}"); } } + +// XML comments and processing instructions are not field text (XML 1.0 §§2.5–2.6). +fn split_text_values() -> [(&'static str, &'static str); 5] { + [ + ("Ada Lovelace", "Ada Lovelace"), + ("Ada", "Ada"), + ("Ada Lovelace", "Ada Lovelace"), + (" Å]]>Ω ", " Å<&>Ω "), + ("", ""), + ] +} + +#[test] +fn xfdf_data_values_join_text_around_xml_comments_and_instructions() { + for (xml_text, expected) in split_text_values() { + let xml = format!(r#"{xml_text}"#); + assert_eq!(data_values(xml.as_bytes()).unwrap(), vec![("Name".into(), vec![expected.to_string()])], "{xml_text}"); + } +} + +#[test] +fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { + for (xml_text, expected) in split_text_values() { + let xml = format!(r#"{xml_text}"#); + let mut doc = one_field(); + set_value(&mut doc, "Name", &FieldValue::Text("old value".into())).unwrap(); + let report = import(&mut doc, xml.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty(), "{report:?}"); + let saved = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + let want: Vec<&str> = if expected.is_empty() { vec![] } else { vec![expected] }; + assert_eq!(values(&reopened)[0].1, want, "{xml_text}"); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 479189b759..f2f481ce94 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -116,12 +116,12 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | @@ -178,6 +178,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | | 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 57159d7575..e3a1ed2141 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4687,8 +4687,8 @@ milestone = "M6" status = "shipped" commands = ["form.import_data"] tools = ["doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)" +evidence = ["crates/xfdf/src/tests.rs::xfdf_import_keeps_text_around_xml_comments_and_instructions", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] +notes = "XFDF, FDF; XFDF text survives XML comments and processing instructions; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)" [[feature]] id = "form.export-data" From 3805be7669ea9ac4a4ed887859754f965fe5d7f5 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 15 Oct 2026 01:52:12 -0600 Subject: [PATCH 09/19] M14: store XDG preferences in configuration directory (#1395) (#1536) Generated with Codex for @DWAA1660. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + apps/pdfcraft/src/logging.rs | 2 +- apps/pdfcraft/src/main.rs | 31 ++++-- apps/pdfcraft/src/settings.rs | 194 ++++++++++++++++++++++++++++++++++ docs/development.md | 17 ++- docs/roadmap.md | 3 +- parity/acrobat-features.toml | 4 +- 7 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 apps/pdfcraft/src/settings.rs diff --git a/ROADMAP.md b/ROADMAP.md index b9158839a5..9b67d4ec66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged. + - **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged. - 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged. diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index a62d300f6b..71ed1d2d2f 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -3,7 +3,7 @@ //! Without a logger every `log::warn!`/`log::error!` in the workspace (a failed autosave, a page //! that would not render, a render worker that could not start, the panic hook's report) vanished. //! A launch from a desktop menu has no terminal, so the file is what a bug report can attach: -//! `/logs/pdfcraft.log`, next to `app.ron` (see `settings_dir` in `main.rs`). Each +//! `/logs/pdfcraft.log` (the PdfCraft eframe storage directory, or portable data). Each //! start moves the previous log to `pdfcraft.1.log` (and that one to `.2`), so the log of a run //! that crashed survives the next launch. //! diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index db855fdacd..7a0bb20432 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -37,6 +37,7 @@ mod logging; #[cfg(target_os = "windows")] mod motion; mod reopen; +mod settings; mod single_instance; mod text_scale; mod updates; @@ -57,14 +58,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256 /// The app was called PrintCraft before; settings saved then are under this key. const LEGACY_STORAGE_KEY: &str = "printcraft"; -/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode -/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the -/// app id; keep it under "PdfCraft". +/// The preferences folder (`app.ron`, docs/development.md). In portable mode +/// it is `PdfCraftData` beside the executable (#157). XDG desktops use the configuration base; +/// other platforms retain eframe's PdfCraft folder. Logs and recovery stay in the data folder. fn settings_dir() -> Option { - if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() { - return Some(dir.to_path_buf()); + let legacy = eframe::storage_dir("PdfCraft"); + match settings::directory( + cfg!(all(unix, not(target_os = "macos"))), + pdfcraft_ui_egui::portable::data_dir(), + legacy.clone(), + std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from), + std::env::home_dir(), + ) { + Ok(dir) => dir, + Err(e) => { + log::warn!("could not migrate preferences to the XDG configuration folder; using the original location: {e}"); + legacy + } } - eframe::storage_dir("PdfCraft") } /// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new @@ -75,7 +86,7 @@ fn migrate_legacy_folders() { if pdfcraft_ui_egui::portable::data_dir().is_some() { return; } - let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())]; + let mut moves = vec![(eframe::storage_dir("PrintCraft"), eframe::storage_dir("PdfCraft"))]; // Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%. if cfg!(windows) { let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(std::path::PathBuf::from); @@ -203,10 +214,12 @@ fn main() -> eframe::Result { // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; migrate_legacy_folders(); - // The log file lives in the settings folder; opened after the arguments (so `--version` leaves + // The log file stays in the data folder; opened after the arguments (so `--version` leaves // no file behind) and after the PrintCraft migration (which a fresh folder would block). // Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, settings_dir()) { + if let (Some(logger), Some(dir)) = + (logger, pdfcraft_ui_egui::portable::data_dir().map(std::path::Path::to_path_buf).or_else(|| eframe::storage_dir("PdfCraft"))) + { match logger.attach_dir(&dir.join("logs")) { Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()), // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. diff --git a/apps/pdfcraft/src/settings.rs b/apps/pdfcraft/src/settings.rs new file mode 100644 index 0000000000..950566a2e0 --- /dev/null +++ b/apps/pdfcraft/src/settings.rs @@ -0,0 +1,194 @@ +//! Platform preference location, separate from logs and crash recovery. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static STAGING_ID: AtomicU64 = AtomicU64::new(0); + +pub fn directory( + xdg: bool, + portable: Option<&Path>, + legacy: Option, + config_home: Option, + home: Option, +) -> std::io::Result> { + if let Some(portable) = portable { + return Ok(Some(portable.to_path_buf())); + } + if !xdg { + return Ok(legacy); + } + // XDG requires absolute environment paths; empty and relative overrides are ignored. + let preferred = config_home.filter(|p| p.is_absolute()).or_else(|| home.filter(|p| p.is_absolute()).map(|p| p.join(".config"))); + let Some(preferred) = preferred.map(|p| p.join("pdfcraft")) else { return Ok(legacy) }; + let target = preferred.join("app.ron"); + if std::fs::symlink_metadata(&target).is_ok() { + return Ok(Some(preferred)); + } + create_private_directory(&preferred)?; + if let Some(source) = legacy.map(|p| p.join("app.ron")) { + match std::fs::File::open(&source) { + Ok(mut source) => migrate(&mut source, &preferred, &target)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(Some(preferred)) +} + +/// Create private configuration directories without changing existing directory permissions. +fn create_private_directory(directory: &Path) -> std::io::Result<()> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(directory) +} + +/// Publish a complete copy without replacing a concurrent launch's preferences. Keep the +/// original file and all other data (especially recovery and digital IDs) where they were. +fn migrate(source: &mut std::fs::File, directory: &Path, target: &Path) -> std::io::Result<()> { + create_private_directory(directory)?; + for _ in 0..32 { + let id = STAGING_ID.fetch_add(1, Ordering::Relaxed); + let staging = directory.join(format!(".app.ron-migrate-{}-{id}", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = match options.open(&staging) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + }; + let result = (|| { + std::io::copy(source, &mut file)?; + file.sync_all()?; + // Both files are in the same directory/filesystem. Unlike rename, hard_link + // atomically refuses an existing destination on Unix as well as Windows. + match std::fs::hard_link(&staging, target) { + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + result => result, + } + })(); + drop(file); + let _ = std::fs::remove_file(&staging); + return result; + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free settings migration staging name")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xdg_preferences_use_config_instead_of_data() { + let fixture = Fixture::new(); + let home = fixture.0.join("home"); + let data = home.join(".local/share/pdfcraft"); + assert_eq!(directory(true, None, Some(data), None, Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft"))); + } + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let id = STAGING_ID.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-settings-{}-{id}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + Self(dir) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn xdg_override_and_invalid_overrides() { + let f = Fixture::new(); + let home = f.0.join("home"); + let config = f.0.join("custom-config"); + assert_eq!(directory(true, None, None, Some(config.clone()), Some(home.clone())).unwrap(), Some(config.join("pdfcraft"))); + for invalid in [PathBuf::new(), PathBuf::from("relative")] { + assert_eq!(directory(true, None, None, Some(invalid), Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft"))); + } + assert_eq!(directory(true, None, Some(f.0.clone()), None, None).unwrap(), Some(f.0.clone())); + } + + #[test] + fn migration_preserves_preferences_and_other_data() { + let f = Fixture::new(); + let data = f.0.join("data/pdfcraft"); + let config = f.0.join("config"); + std::fs::create_dir_all(data.join("recovery")).unwrap(); + std::fs::write(data.join("app.ron"), b"(theme:Dark,language:German)").unwrap(); + std::fs::write(data.join("recovery/unsaved.pdf"), b"UNSAVED").unwrap(); + std::fs::write(data.join("digital-id.p12"), b"KEY").unwrap(); + let selected = directory(true, None, Some(data.clone()), Some(config.clone()), None).unwrap().unwrap(); + assert_eq!(selected, config.join("pdfcraft")); + assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), std::fs::read(data.join("app.ron")).unwrap()); + assert_eq!(std::fs::read(data.join("recovery/unsaved.pdf")).unwrap(), b"UNSAVED"); + assert_eq!(std::fs::read(data.join("digital-id.p12")).unwrap(), b"KEY"); + // The migrated file is a copy, not a link to the original settings. + std::fs::write(selected.join("app.ron"), b"NEW").unwrap(); + assert_eq!(std::fs::read(data.join("app.ron")).unwrap(), b"(theme:Dark,language:German)"); + assert_eq!(directory(true, None, Some(data), Some(config), None).unwrap(), Some(selected.clone())); + assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(selected).unwrap().count(), 1); + } + + #[test] + fn existing_or_concurrently_created_preferences_are_never_replaced() { + let f = Fixture::new(); + let source = f.0.join("old.ron"); + let config = f.0.join("config/pdfcraft"); + std::fs::write(&source, b"OLD").unwrap(); + std::fs::create_dir_all(&config).unwrap(); + let target = config.join("app.ron"); + std::fs::write(&target, b"CURRENT").unwrap(); + migrate(&mut std::fs::File::open(source).unwrap(), &config, &target).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"CURRENT"); + assert_eq!(std::fs::read_dir(config).unwrap().count(), 1); + } + + #[test] + fn failed_migration_keeps_original_available() { + let f = Fixture::new(); + std::fs::write(f.0.join("app.ron"), b"OLD").unwrap(); + let blocked = f.0.join("blocked"); + std::fs::write(&blocked, b"NOT A DIRECTORY").unwrap(); + assert!(directory(true, None, Some(f.0.clone()), Some(blocked), None).is_err()); + assert_eq!(std::fs::read(f.0.join("app.ron")).unwrap(), b"OLD"); + } + + #[test] + fn portable_and_non_xdg_platforms_keep_their_locations() { + let f = Fixture::new(); + let portable = f.0.join("PdfCraftData"); + for xdg in [true, false] { + assert_eq!(directory(xdg, Some(&portable), Some(f.0.clone()), Some(f.0.clone()), None).unwrap(), Some(portable.clone())); + } + assert_eq!(directory(false, None, Some(f.0.clone()), Some(portable), None).unwrap(), Some(f.0.clone())); + assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 0, "no migration outside XDG mode"); + } + + #[cfg(unix)] + #[test] + fn new_config_directory_is_private_and_existing_permissions_are_preserved() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new(); + let selected = directory(true, None, None, Some(f.0.join("config")), None).unwrap().unwrap(); + assert_eq!(std::fs::metadata(&selected).unwrap().permissions().mode() & 0o777, 0o700); + std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o750)).unwrap(); + create_private_directory(&selected).unwrap(); + assert_eq!(std::fs::metadata(selected).unwrap().permissions().mode() & 0o777, 0o750); + } +} diff --git a/docs/development.md b/docs/development.md index 59483b533b..1d5bb80951 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,7 +50,7 @@ do not contact a desktop session bus. Page and document colours retain their exi ## Logs The desktop app writes its `log` records to standard error and to `logs/pdfcraft.log` in its -settings folder, next to `app.ron`: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or +data folder: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or `$XDG_DATA_HOME/pdfcraft/logs/`), macOS `~/Library/Application Support/PdfCraft/logs/`, Windows `%APPDATA%\PdfCraft\data\logs\`. In portable mode (a `portable.txt` file next to the executable, as the Windows portable zip ships) the settings folder is `PdfCraftData\` beside the executable, and @@ -105,13 +105,26 @@ reach this retry instead of the uncaptured-error panic handler. This does not fi driver's shader compilation, and explicit `PDFCRAFT_RENDERER=wgpu` still disables the retry. `PDFCRAFT_RENDERER` skips it or chooses OpenGL from the start. +## Preferences + +Linux and FreeBSD store `app.ron` in `$XDG_CONFIG_HOME/pdfcraft/`, or +`~/.config/pdfcraft/` when that variable is unset, empty or relative. This follows the +[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/). +On first launch after upgrading, existing `app.ron` preferences are copied from the old +data folder without replacing an existing configuration file. The old copy is retained; +logs, crash recovery and digital IDs stay in their data locations. If migration fails, +the app logs the failure and continues using the original preferences folder. +The existing PrintCraft-to-PdfCraft migration runs first. macOS, Windows and portable +copies retain their current preferences locations. + ## Environment variables | Variable | Effect | |---|---| | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `RUST_BACKTRACE` | `1` adds a backtrace to the report of an internal error | -| `XDG_DATA_HOME` | Linux/FreeBSD: base of the settings folder (`pdfcraft/`), the log folder and crash recovery | +| `XDG_CONFIG_HOME` | Linux/FreeBSD: absolute configuration base for `pdfcraft/app.ron`; defaults to `~/.config` | +| `XDG_DATA_HOME` | Linux/FreeBSD: base of the log folder and crash recovery; previous preferences are migrated from here | | `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | | `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL | | `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. PdfCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device at start-up | diff --git a/docs/roadmap.md b/docs/roadmap.md index f2f481ce94..0316ccf22f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,7 +132,7 @@ total matches the parity estimate. | 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
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
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
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, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. 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 | +| 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, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. 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 XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | | 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 | | 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, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. 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 | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | @@ -178,6 +178,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | | 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | | 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e3a1ed2141..7f6407500b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9807,8 +9807,8 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = ["js_enabled"] -evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override"] -notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet." +evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override", "apps/pdfcraft/src/settings.rs::xdg_preferences_use_config_instead_of_data", "apps/pdfcraft/src/settings.rs::migration_preserves_preferences_and_other_data"] +notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet. XDG desktops store app.ron under XDG_CONFIG_HOME/pdfcraft or ~/.config/pdfcraft; existing data-folder preferences are copied without replacing configured preferences, with fallback on migration errors. Logs and recovery retain data locations. Portable/non-XDG locations are unchanged." [[feature]] id = "misc.prefs-general" From d1bc2001156c8036d8bc97aaac1a41091975fd36 Mon Sep 17 00:00:00 2001 From: Yousef Mojahid <156470973+YouseifUS@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:52:33 +0300 Subject: [PATCH 10/19] M7: grow text boxes and edit existing text (#1539) * M7: grow text boxes and edit existing text Text boxes now grow to the page edge and vertically as needed, retaining their size when saved. Clicking native or added text in either text tool edits the existing item instead of layering a duplicate over it.\n\nCo-authored-by: OpenAI gpt-6.1-sol * #1539: build against main's Document::added() accessor and draft-aware prepare Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/automation/README.md | 4 + crates/automation/src/lib.rs | 13 +++- crates/automation/src/tools.rs | 3 +- crates/automation/tests/automation.rs | 23 ++++++ crates/edit/src/added.rs | 5 ++ crates/edit/src/tests.rs | 24 ++++++ crates/edit/src/text_regions.rs | 5 +- crates/engine/src/lib.rs | 1 + crates/engine/src/text_preview.rs | 14 ++++ crates/ui-egui/src/canvas.rs | 55 +++++++++---- crates/ui-egui/src/content_ui.rs | 72 +++++++++++++---- crates/ui-egui/src/edit_text_ui.rs | 65 ++++++++++++---- crates/ui-egui/tests/content.rs | 107 ++++++++++++++++++++++++++ crates/ui-egui/tests/editing.rs | 83 ++++++++++++++++++++ docs/editing-existing-text.md | 6 ++ docs/roadmap.md | 1 + 17 files changed, 435 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9b67d4ec66..c2cd0f8819 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged. - **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged. - **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged. diff --git a/crates/automation/README.md b/crates/automation/README.md index cb1d85c1f9..4e27702a32 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -157,6 +157,10 @@ For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based i `text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the working document unchanged. +`page_render.hide_added_text` does the same for an added text item's 1-based index from +`content_list` or `object_list`. It removes only that item in a read-only preview and is +mutually exclusive with `hide_text_block`. + ## Existing text and document fonts diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1933c7d0d7..ad18f71124 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1798,12 +1798,23 @@ impl Automation { return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); } let scale = (dpi / 72.0) as f32; - let out = if let Some(block) = a.opt_int("hide_text_block")? { + let block = a.opt_int("hide_text_block")?; + let added = a.opt_int("hide_added_text")?; + if block.is_some() && added.is_some() { + return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); + } + let out = if let Some(block) = block { let index = usize::try_from(block) .ok() .and_then(|n| n.checked_sub(1)) .ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?; self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else if let Some(added) = added { + let index = usize::try_from(added) + .ok() + .and_then(|n| n.checked_sub(1)) + .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; + self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? } else { self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cd83fd5b42..5e89aef78a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -161,7 +161,8 @@ pub fn tools() -> Vec { t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, - "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." } + "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }, + "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 58f4a5272d..f45aec3f57 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -621,6 +621,29 @@ fn text_editor_background_is_read_only_and_removes_source_glyphs() { } } +#[test] +fn added_text_editor_background_hides_only_the_item_without_editing() { + let dir = workdir("added-text-preview"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let render = json!({"doc":doc,"page":1,"dpi":72}); + let before = a.call("page_render", &render).unwrap(); + ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Retype this label","at":[20,20],"width":160})); + let drawn = a.call("page_render", &render).unwrap(); + assert_ne!(drawn, before); + let state = ok(&mut a, "doc_info", json!({"doc":doc})); + let items = ok(&mut a, "content_list", json!({"doc":doc})); + let preview = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_added_text":1})).unwrap(); + assert_eq!(preview, before, "native text and the page background stay intact"); + assert_eq!(a.call("page_render", &render).unwrap(), drawn); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc})), state); + assert_eq!(ok(&mut a, "content_list", json!({"doc":doc})), items); + for index in [-1, 0, 2, i64::MAX] { + assert!(a.call("page_render", &json!({"doc":doc,"page":1,"hide_added_text":index})).is_err()); + } + assert!(a.call("page_render", &json!({"doc":doc,"page":1,"hide_added_text":1,"hide_text_block":1})).is_err()); +} + #[test] fn edit_undo_redo_and_save_round_trip() { let dir = workdir("edit"); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 2b6c9d13a6..d246df725b 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -501,6 +501,11 @@ pub fn text_rect(t: &AddedText) -> [f64; 4] { [r[0], r[3] - h, r[2], r[3]] } +/// The widest explicit line before wrapping, measured with the PDF writing fonts. +pub fn text_width(t: &AddedText) -> f64 { + t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max) +} + fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index aab8134bbc..b542142df8 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3431,6 +3431,30 @@ fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() { assert_eq!(blocks[2].text, "Other column"); } +#[test] +fn a_single_row_with_mixed_styles_grows_before_wrapping() { + let mut doc = text_page("BT /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf (bold) Tj /F1 10 Tf ( text.) Tj ET"); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| { + *f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold")); + }) + .unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let index = blocks.iter().position(|b| b.editor_text == "Plain bold text.").unwrap(); + let source = &blocks[index]; + assert_eq!(source.rows.len(), 1); + assert!(source.lines.len() > 1); + let draft = "Plain bold text. More words on the same line"; + text::replace_block(&mut doc, 0, index, draft).unwrap(); + let edited = text::text_blocks(&reopen(&doc), 0).unwrap(); + let block = edited.iter().find(|b| b.text == draft).unwrap(); + assert_eq!(block.rows.len(), 1, "a style boundary must not pin a single row to its original width"); + assert!((block.rect[0] - source.rect[0]).abs() < 0.1); + assert!(block.rect[2] > source.rect[2] + 20.0); +} + #[test] fn explicit_editor_rows_survive_single_run_edit_and_reopen() { let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET"); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 0f6a251615..2d7baf519f 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -379,8 +379,9 @@ pub(super) fn rewrite_styled_block( if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); } - // A rewritten OCR line grows to the right rather than wrapping onto the scanned line below. - let natural = if block.invisible { + // A visual row grows even when it contains several source/font runs. Multi-row + // paragraphs retain their container width; an explicit width always wins. + let natural = if block.invisible || block.rows.len() == 1 { (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) } else { block.rect[2] - block.rect[0] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c85556c236..eb0a11825d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -52,6 +52,7 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width}; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d180169677..d533faf7e0 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -33,6 +33,20 @@ impl TextEditPreview { } impl Document { + /// Background for retyping an added text item, without changing its saved content. + pub fn added_text_preview(&self, page: usize, index: usize) -> Result { + guard(|| { + if !self.added().iter().filter(|a| a.page == page).nth(index).is_some_and(|a| matches!(a.content, crate::AddedContent::Text(_))) { + return Err("the selected added item is not text".into()); + } + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: self.config.clone() }) + })? + } + /// Measure a draft without changing bytes, dirty state, or undo history. pub fn text_edit_layout( &self, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c2e6e82011..8bc3a5c7a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2557,7 +2557,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.prepare.selected = None; } view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); - view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), scale); + view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; if editing_content { @@ -2777,7 +2777,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } - view.text_background.paint(painter, &xf, i, view.line_editor.as_ref()); + view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); if editing_content { let page_tex = view.pages.get(&i).map(|p| p.tex.id()); view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); @@ -2915,9 +2915,46 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view); let on_objects = !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view); + let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { + let generation = doc.edit_generation(); + match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } + } + } else { + Vec::new() + }; + let over_source = tool == QuickTool::AddText + && pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, &added, p) + && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) + }); + let source_blocked = over_source && view.content.hold_blocked(); + if over_source && resp.clicked() && !source_blocked { + if let Some(edit) = view.content.finish_draft(&added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } let on_content = !on_objects && editing_content && can_modify && { - crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view) - || tool == QuickTool::AddText + source_blocked + || crate::content_ui::page_input( + ui, + &resp, + &xf, + i, + info, + &added, + tool == QuickTool::AddText && !over_source, + matches!(tool, QuickTool::AddText | QuickTool::EditText), + &text_style, + view, + ) + || tool == QuickTool::AddText && !over_source }; // Added content owns its gesture; its source text must not queue a second edit. // Edit text & images boxes every paragraph and image whenever the tool is on. Only the @@ -2925,16 +2962,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn // instead of vanishing until the pointer comes back. let others_own_pointer = on_objects || on_content; - let on_edit_text = tool == QuickTool::EditText && can_modify && { + let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { let generation = doc.edit_generation(); - let lines = match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - }; let images = match view.edit_images.get(&i) { Some((g, l)) if *g == generation => l.clone(), _ => { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 52f8e3cb43..5f5ef32f77 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -86,9 +86,13 @@ pub struct ContentView { pub select_added: Option<(usize, usize)>, grab: Option<(usize, Grab, Pos2)>, undrawable: UndrawableMemo, + layout: Option<(AddedText, f32, f64, [f64; 4])>, } impl ContentView { + pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option { + self.draft.take().and_then(|d| finish(self, d, added)) + } /// The draft's first character the standard fonts can't draw. Such a draft is kept open, and /// counts as unsaved work, until it is corrected or discarded. pub(crate) fn blocked(&self) -> Option { @@ -248,6 +252,10 @@ fn on_page(added: &[Added], page: usize) -> Vec<(usize, &Added)> { added.iter().filter(|a| a.page == page).enumerate().collect() } +pub(crate) fn hits_added(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], point: Pos2) -> bool { + on_page(added, page).iter().any(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(point)) +} + fn handles(r: Rect) -> [(Pos2, bool, bool); 4] { [(r.left_top(), true, true), (r.right_top(), false, true), (r.left_bottom(), true, false), (r.right_bottom(), false, false)] } @@ -306,6 +314,7 @@ pub(crate) fn page_input( info: &DocInfo, added: &[Added], adding_text: bool, + edit_on_click: bool, style: &AddedText, view: &mut DocView, ) -> bool { @@ -363,14 +372,10 @@ pub(crate) fn page_input( let at = to_display(xf, info, page, p); let mut style = style.clone(); style.text.clear(); - cv.draft = Some(TextDraft { - page, - index: None, - rect: [at[0], at[1] - style.size * 1.2, at[0] + 200.0, at[1]], - text: String::new(), - style, - focus: true, - }); + let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64); + let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]]; + style.rect = rect; + cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true }); consumed = true; if typed.is_some() { view.pending_edit = typed; @@ -395,13 +400,19 @@ pub(crate) fn page_input( } return consumed; } - if resp.double_clicked() + if (resp.double_clicked() || edit_on_click && resp.clicked() && corner.is_none()) && let Some((i, a)) = hit && let AddedContent::Text(t) = &a.content { if cv.hold_blocked() { return true; } + if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) { + return true; + } + if let Some(edit) = cv.finish_draft(added) { + view.pending_edit = Some(edit); + } cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true }); cv.selected = Some((page, i)); consumed = true; @@ -478,16 +489,46 @@ pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) { /// committed (a click away, or Done), and whether Done finished adding text. Discard or Escape /// throws the draft away. pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option, bool) { - let Some((page, rect)) = view.content.draft.as_ref().map(|d| (d.page, d.rect)) else { return (None, false) }; + let Some(page) = view.content.draft.as_ref().map(|d| d.page) else { return (None, false) }; let Some(xf) = view.page_xform(page) else { return (None, false) }; - let r = screen_rect(&xf, info, page, rect); let zoom = xf.rect.width() / xf.pw.max(1.0); + let Some(draft) = view.content.draft.as_mut() else { return (None, false) }; + let font = crate::edit_text_ui::editor_font(&draft.style, (draft.style.size as f32 * zoom).clamp(1.0, 300.0)); + let text = AddedText { text: draft.text.clone(), ..draft.style.clone() }; + let Some(page_width) = info.pages.get(page).map(|p| p.width as f64) else { return (None, false) }; + if let Some((key, z, width, rect)) = &view.content.layout + && *key == text + && *z == zoom + && *width == page_width + { + draft.rect = *rect; + } else { + let source = draft.style.rect; + if draft.index.is_some() && draft.text == draft.style.text { + draft.rect = source; + } else { + let natural = ctx.fonts_mut(|f| f.layout_no_wrap(draft.text.clone(), font.clone(), Color32::BLACK).size().x) as f64 / zoom as f64; + let width = natural.max(pdfcraft_engine::added_text_width(&text)) + 2.0; + let available = page_width - source[0]; + let width = width.max(source[2] - source[0]).min(available.max(1.0)); + draft.rect = [source[0], source[1], source[0] + width, source[3]]; + draft.rect = pdfcraft_engine::added_text_rect(&AddedText { rect: draft.rect, ..text.clone() }); + } + view.content.layout = Some((text, zoom, page_width, draft.rect)); + } + let r = screen_rect(&xf, info, page, draft.rect); + let source_rect = draft.index.map(|_| screen_rect(&xf, info, page, draft.style.rect)); + let background_ready = draft.index.is_some_and(|index| view.text_background.ready_added(page, index)); let tokens = Tokens::get(ctx); let (mut commit, mut cancel, mut done) = (false, false, false); let mut undrawable = None; let memo = &view.content.undrawable; - egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { + egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).constrain(false).show(ctx, |ui| { let Some(t) = view.content.draft.as_mut() else { return }; + ui.set_width(r.width() + 64.0); + if let Some(source_rect) = source_rect.filter(|_| !background_ready) { + ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE); + } // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); @@ -495,10 +536,10 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad ui.spacing_mut().item_spacing.x = 4.0; let resp = ui.add( egui::TextEdit::multiline(&mut t.text) - .font(egui::FontId::proportional((t.style.size as f32 * zoom).max(8.0))) + .font(font) .desired_width(r.width().max(60.0)) .desired_rows(1) - .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 235)) + .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) .id_salt("added-text-edit"), @@ -558,7 +599,8 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { Some(i) => { let (_, a) = on_page(added, d.page).into_iter().find(|(k, _)| *k == i)?; let AddedContent::Text(old) = &a.content else { return None }; - (old.text != text).then(|| Edit::UpdateContent { page: d.page, index: i, content: AddedContent::Text(AddedText { text, ..old.clone() }) }) + let updated = AddedText { text, rect: d.rect, ..d.style }; + (*old != updated).then_some(Edit::UpdateContent { page: d.page, index: i, content: AddedContent::Text(updated) }) } } } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 3c0c18a5ec..d8881ba47d 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -15,11 +15,17 @@ use crate::canvas::{DocView, PageXform}; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +#[derive(Clone, Copy, PartialEq)] +enum TextTarget { + Block(usize), + Added(usize), +} + /// A cached, read-only page background. The source glyphs are removed before rasterization; /// colour, pictures and unselected text beneath the editor remain visible. #[derive(Default)] pub(crate) struct TextBackground { - key: Option<(u64, usize, usize)>, + key: Option<(u64, usize, TextTarget)>, renderer: Option, texture: Option, tag: Option, @@ -33,12 +39,26 @@ impl TextBackground { self.renderer.is_some() && self.received != self.tag } - pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, editor: Option<&LineEditor>, scale: f32) { - let key = editor.map(|ed| (doc.edit_generation(), ed.page, ed.block)); + pub fn prepare( + &mut self, + ctx: &egui::Context, + doc: &pdfcraft_engine::Document, + editor: Option<&LineEditor>, + draft: Option<&crate::content_ui::TextDraft>, + scale: f32, + ) { + let key = editor + .map(|ed| (doc.edit_generation(), ed.page, TextTarget::Block(ed.block))) + .or_else(|| draft.and_then(|d| d.index.map(|index| (doc.edit_generation(), d.page, TextTarget::Added(index))))); if self.key != key { *self = Self { key, ..Default::default() }; - if let Some((_, page, block)) = key { - self.renderer = doc.text_edit_preview(page, block).ok().map(|p| p.renderer()); + if let Some((_, page, target)) = key { + self.renderer = match target { + TextTarget::Block(block) => doc.text_edit_preview(page, block), + TextTarget::Added(index) => doc.added_text_preview(page, index), + } + .ok() + .map(|p| p.renderer()); } } if let Some(ed) = editor { @@ -82,11 +102,26 @@ impl TextBackground { } pub fn ready(&self, page: usize, block: usize) -> bool { - self.key.is_some_and(|(_, p, b)| (p, b) == (page, block)) && self.texture.is_some() + self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))) && self.texture.is_some() } - pub fn paint(&self, painter: &egui::Painter, xf: &PageXform, page: usize, editor: Option<&LineEditor>) { - let Some(_) = editor.filter(|ed| ed.page == page && self.ready(page, ed.block)) else { return }; + pub fn ready_added(&self, page: usize, index: usize) -> bool { + self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Added(index))) && self.texture.is_some() + } + + pub fn paint( + &self, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + editor: Option<&LineEditor>, + draft: Option<&crate::content_ui::TextDraft>, + ) { + let ready = editor.is_some_and(|ed| ed.page == page && self.ready(page, ed.block)) + || draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index))); + if !ready { + return; + } let Some(tex) = &self.texture else { return }; // Font metrics can underestimate the actual glyph ink. Paint the complete textless // page so overhanging glyphs cannot remain outside the estimated source rectangle. @@ -273,7 +308,7 @@ fn look_of(b: &pdfcraft_engine::TextBlock) -> pdfcraft_engine::AddedText { source_look(&b.base_font, b.size, b.color, b.bold, b.italic) } -fn editor_font(look: &pdfcraft_engine::AddedText, size: f32) -> FontId { +pub(crate) fn editor_font(look: &pdfcraft_engine::AddedText, size: f32) -> FontId { let family = match (look.family, look.bold) { (pdfcraft_engine::FontFamily::Courier, _) => FontFamily::Monospace, (_, true) => FontFamily::Name("semibold".into()), @@ -1304,7 +1339,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // extent: single rows grow toward the page edge, paragraphs rewrap at their // source width, and the visible frame grows/shrinks vertically with their rows. // An OCR phrase (several stretched words on one scanned line) also grows rightwards. - let single_run = ed.source.as_ref().is_some_and(|(block, _)| block.lines.len() == 1 || block.invisible); + let single_run = ed.source.as_ref().is_some_and(|(block, _)| block.rows.len() == 1 || block.invisible); let width = if !ed.multiline && single_run && !ed.text.contains('\n') && ed.has_changes() { let job = editor_job(ui, ed, &ed.text, scale, f32::INFINITY); let natural = ui.fonts_mut(|f| f.layout_job(job)).size().x; @@ -1449,7 +1484,7 @@ mod tests { let ctx = egui::Context::default(); let mut background = TextBackground::default(); for _ in 0..100 { - background.prepare(&ctx, doc, Some(&ed), 1.0); + background.prepare(&ctx, doc, Some(&ed), None, 1.0); if background.ready(0, 0) { break; } @@ -1458,12 +1493,12 @@ mod tests { assert!(background.ready(0, 0)); assert!(!background.pending()); let texture = background.texture.as_ref().unwrap().id(); - background.prepare(&ctx, doc, Some(&ed), 1.0); + background.prepare(&ctx, doc, Some(&ed), None, 1.0); assert_eq!(background.texture.as_ref().unwrap().id(), texture); assert_eq!(doc.bytes, source); assert_eq!(doc.edit_generation(), generation); assert!(!doc.dirty && doc.can_undo().is_none()); - background.prepare(&ctx, doc, None, 1.0); + background.prepare(&ctx, doc, None, None, 1.0); assert!(background.texture.is_none() && background.renderer.is_none()); } @@ -1478,7 +1513,7 @@ mod tests { let ctx = egui::Context::default(); ctx.begin_pass(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }); let mut background = TextBackground::default(); - background.prepare(&ctx, doc, Some(&ed), 8.0); + background.prepare(&ctx, doc, Some(&ed), None, 8.0); let expected = 8.0f32.min(crate::canvas::TILE_THRESHOLD / page.width.max(page.height)); assert!(expected > crate::canvas::BASE_SIDE / page.width.max(page.height), "the test page needs the larger side"); assert_eq!(background.tag, Some((expected * 1000.0) as u64)); @@ -1486,7 +1521,7 @@ mod tests { ctx.end_pass().textures_delta.clear(); ctx.begin_pass(egui::RawInput { max_texture_side: Some(2048), ..Default::default() }); let mut small = TextBackground::default(); - small.prepare(&ctx, doc, Some(&ed), 8.0); + small.prepare(&ctx, doc, Some(&ed), None, 8.0); assert_eq!(small.tag, Some(((2048.0 / page.width.max(page.height)) * 1000.0) as u64)); ctx.end_pass().textures_delta.clear(); } diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index b656593c43..dd2c032eff 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -76,6 +76,113 @@ fn texts(h: &Harness<'static, PdfCraftApp>) -> Vec { .collect() } +#[test] +fn text_tool_drafts_grow_for_latin_and_arabic() { + for text in ["Wide words ".repeat(8), "سلام عليكم ".repeat(8)] { + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let point = at(&h, 40.0, 300.0); + click(&mut h, point); + let before = h.state().views[0].content.draft.as_ref().unwrap().rect; + h.event(egui::Event::Text(text.clone())); + h.run_steps(4); + let draft = h.state().views[0].content.draft.as_ref().unwrap(); + assert!(draft.rect[2] - draft.rect[0] > before[2] - before[0] + 10.0, "the editor grows before wrapping: {}", text); + assert!(draft.rect[2] <= 300.0 && draft.rect[1] < before[1], "the frame stays on the page and grows vertically"); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + let language = if text.starts_with("Wide") { "latin" } else { "arabic" }; + h.render().unwrap().save(format!("{dir}/growing-{language}-text.png")).unwrap(); + } + h.get_by_label("Done adding text").click(); + h.run_steps(3); + if text.starts_with("سلام") && pdfcraft_fonts::document_arabic_font().is_none() { + assert!(h.state().views[0].content.draft.is_some(), "without a document font the draft remains available"); + assert!(added(&h).is_empty()); + continue; + } + let pdfcraft_engine::AddedContent::Text(saved) = &added(&h)[0].content else { panic!() }; + assert_eq!(saved.text, text.trim_end()); + assert!(saved.rect[2] - saved.rect[0] > before[2] - before[0] + 10.0, "Save uses the grown width"); + } +} + +#[test] +fn text_tool_single_click_retypes_added_text_without_another_item() { + for command in ["edit.text", "edit.edit_text"] { + for (original, replacement) in [("Original", "Replacement"), ("سلام", "سلام عليكم ورحمة الله")] { + if original == "سلام" && pdfcraft_fonts::document_arabic_font().is_none() { + continue; + } + let mut h = harness(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: original.into(), ..Default::default() }, + })); + assert!(h.state_mut().execute(command)); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + let draft = h.state().views[0].content.draft.as_ref().expect("one click edits the existing item"); + assert_eq!(draft.index, Some(0)); + assert_eq!(draft.text, original); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(replacement.into())); + h.run_steps(3); + h.get_by_label("Done adding text").click(); + h.run_steps(3); + assert_eq!(texts(&h), [replacement]); + let state = h.state(); + let bytes = state.session.save_bytes(state.views[0].id).unwrap(); + let mut reopened = pdfcraft_engine::Session::new(); + let id = reopened.open("again.pdf", None, bytes, None).unwrap(); + assert_eq!(reopened.get(id).unwrap().added().len(), 1); + let pdfcraft_engine::AddedContent::Text(text) = &reopened.get(id).unwrap().added()[0].content else { panic!() }; + assert_eq!(text.text, replacement); + } + } +} + +#[test] +fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_them() { + let mut h = harness(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 220.0], text: "Original glyphs".into(), size: 18.0, ..Default::default() }, + })); + let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(3); + let point = at(&h, 45.0, 185.0); + click(&mut h, point); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + for _ in 0..100 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty()); + let shot = h.render().unwrap(); + for x in 105..140 { + for y in 185..197 { + let pixel = at(&h, x as f32, y as f32); + let [r, g, b, a] = shot.get_pixel(pixel.x as u32, pixel.y as u32).0; + assert!(r > 200 && g > 200 && b > 200 && a == 255, "old glyph ink remains at {x},{y}: {r},{g},{b},{a}"); + } + } + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + shot.save(format!("{dir}/cleared-added-text.png")).unwrap(); + } + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes, "previewing does not edit the document"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(texts(&h), ["Original glyphs"]); + assert!(h.state().views[0].content.draft.is_none()); +} + #[test] fn clicking_elsewhere_keeps_the_text_and_starts_a_new_box() { // #74: a click elsewhere on the page threw away the text being typed and opened a new box. diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 883c367228..65b90dad55 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2626,6 +2626,89 @@ fn editing_existing_text_in_place() { assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it"); } +#[test] +fn text_tool_clicking_source_text_edits_instead_of_adding_a_copy() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let blank = egui::pos2(page.left() + 0.2 * page.width(), page.top() + 0.1 * page.height()); + h.hover_at(blank); + h.run_steps(1); + h.drag_at(blank); + h.run_steps(1); + h.drop_at(blank); + h.run_steps(3); + h.event(egui::Event::Text("Keep this note".into())); + h.run_steps(3); + let point = egui::pos2(page.left() + 40.0 / 200.0 * page.width(), page.top() + 142.0 / 300.0 * page.height()); + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(4); + assert!(h.state().views[0].content.draft.is_none(), "clicking source text must not start an added layer"); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "Page 1"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1, "the previous draft is kept"); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(egui::Event::Text("Replacement".into())); + h.run_steps(3); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Keep this note\nReplacement"]); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1, "editing the source adds no second layer"); +} + +#[test] +fn text_tool_mixed_style_row_grows_in_the_editor_and_saved_pdf() { + let bytes = source_font_fixture_with_text("BT /F1 12 Tf 20 220 Td (Small ) Tj /F2 12 Tf (row) Tj ET"); + 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("mixed-row.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let point = egui::pos2(page.left() + 25.0 / 300.0 * page.width(), page.top() + 75.0 / 300.0 * page.height()); + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(4); + let replacement = "Small row with more words"; + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" with more words".into())); + h.run_steps(4); + let galley = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == replacement => Some(&t.galley), + _ => None, + }) + .expect("the draft is painted"); + assert_eq!(galley.rows.len(), 1, "mixed source runs grow as one visual row"); + if let Ok(path) = std::env::var("PDFCRAFT_MIXED_ROW_SHOT") { + h.render().unwrap().save(path).unwrap(); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(3); + let state = h.state(); + let doc = state.session.get(state.views[0].id).unwrap(); + assert_eq!(doc.text_blocks(0)[0].editor_text, replacement); + assert_eq!(doc.text_blocks(0)[0].rows.len(), 1); + let bytes = state.session.save_bytes(state.views[0].id).unwrap(); + let mut reopened = pdfcraft_engine::Session::new(); + let id = reopened.open("again.pdf", None, bytes, None).unwrap(); + assert_eq!(reopened.get(id).unwrap().text_blocks(0)[0].rows.len(), 1); +} + /// One page whose only line is drawn twice at the same spot (fake bold, as many generated /// documents do). fn double_drawn() -> Vec { diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 50b9261c90..e8bb03b9a4 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -10,6 +10,12 @@ moved because an earlier row wrapped, are rewritten, and formatting a selection only the rows it covers. Changing the whole paragraph's formatting, width or position rewrites every row. +Add Text also opens existing text when it is clicked; a blank-space click starts a new item. +Both text tools retype an added text item with one click. Draft boxes grow to the page edge +before wrapping, and the saved item uses that width. A single visual source row grows across font/style boundaries; +multi-row paragraphs retain their existing wrap width. Retyping uses a preview with only +that added item's old drawing removed, so deleted draft characters do not show underneath. + A subset font contains only some of the original face's glyphs. A new character cannot be reconstructed from a glyph the PDF does not contain. **Load font…** imports an openly licensed, embeddable standalone `.ttf` or `.otf` face. **Installed fonts…** discovers eligible user-installed diff --git a/docs/roadmap.md b/docs/roadmap.md index 0316ccf22f..e6a5ce0397 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -178,6 +178,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | | 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | | 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | | 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | From fcd72747cd368c31fad7a89a4d54880588173001 Mon Sep 17 00:00:00 2001 From: lollinoo <99369406+lollinoo@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:52:55 +0200 Subject: [PATCH 11/19] M6: make list boxes directly selectable and scroll to selection (#1524) (#1543) * M6: make list boxes directly selectable and scroll to selection (#1524) - In-place selection: List boxes are rendered directly on the page and selectable in place. Clicking an option row selects it immediately; Ctrl/Cmd-clicking toggles items in multi-select lists, and Shift-clicking selects ranges, without opening a popup menu. - Appearance and scroll (/TI): When options are selected, /TI (top index) is automatically adjusted so the selected items are scrolled into view within the widget's visible rows, and option rows and selection highlights are drawn into the appearance stream. - Unit and UI tests: Added tests in pdfcraft-forms verifying /TI scrolling and appearance generation for multi-select lists, and UI tests in pdfcraft-ui-egui covering in-place click and Ctrl-click toggling. Also ensured test harness in reload.rs explicitly sets language to en. Fixes #1524 * List boxes: keep the engine's page appearance, consume arrow keys Drop the overpaint of every fillable list box with an egui mock (it ignored the PDF's fonts, colours and /MK /R; the engine redraws the appearance with the new /TI), and consume ArrowUp/ArrowDown in the list overlay so they don't also scroll the page. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 +- crates/forms/src/appearance.rs | 121 ++++++++++++++++++++++++++-- crates/forms/src/author.rs | 1 + crates/forms/src/lib.rs | 28 +++++++ crates/forms/src/tests.rs | 27 +++++++ crates/ui-egui/src/forms_ui.rs | 143 ++++++++++++++++++++++++++------- crates/ui-egui/tests/forms.rs | 71 ++++++++++++++++ 7 files changed, 353 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eb0a11825d..7a858581fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -59,7 +59,7 @@ pub use pdfcraft_edit::{ pub use pdfcraft_forms::{ BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags, + appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags, }; pub use pdfcraft_a11y as a11y; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 6cf90daa31..4ec22f0114 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -318,6 +318,93 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { (c, 0.0) } +/// The width [`frame`] insets the content by: the `/BS` border width when the widget has a +/// border colour, doubled for beveled and inset borders, and 0 without a border. +fn border_width(doc: &Document, wd: &Dict) -> f64 { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 { + return 0.0; + } + let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); + if style == b"B" || style == b"I" { 2.0 * bw } else { bw } +} + +/// Where a list box draws its options: one row per option from the top, starting with option +/// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share +/// it, so a click picks the option drawn under the pointer (#1524). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ListRows { + /// The first option shown (`/TI`). + pub top: usize, + /// From the widget's top edge to the first row, in points. + pub inset: f64, + /// The height of one row, in points. + pub row: f64, + /// The text size, in points. + pub size: f64, +} + +impl ListRows { + /// How many whole rows fit in a list box `height` points tall (at least one). + pub fn visible(&self, height: f64) -> usize { + if !(height.is_finite() && self.row > 0.0) { + return 1; + } + let rows = ((height - self.inset) / self.row).floor(); + if rows >= 1.0 { rows.min(10_000.0) as usize } else { 1 } + } + + /// The option at `y` points below the widget's top edge when the list starts at option `top`, + /// or `None` above the first row. The caller checks it against the number of options. + pub fn option_at(&self, top: usize, y: f64) -> Option { + if !(y.is_finite() && self.row > 0.0) || y < self.inset { + return None; + } + let k = ((y - self.inset) / self.row).floor(); + (k < 1.0e6).then(|| top.saturating_add(k as usize)) + } + + /// The top index that shows as many of the `selected` option indices as fit in `visible` + /// rows, staying at `current` when they are all shown already, among `count` options. + pub fn top_showing(current: usize, selected: &[usize], visible: usize, count: usize) -> usize { + let visible = visible.max(1); + let last_top = count.saturating_sub(visible); + let (Some(&first), Some(&last)) = (selected.iter().min(), selected.iter().max()) else { return current.min(last_top) }; + let top = if first >= current && last < current.saturating_add(visible) { + current + } else if last.saturating_sub(first) >= visible || first < current { + first + } else { + // Scroll down just far enough that the last selected option is the bottom row. + last.saturating_add(1).saturating_sub(visible) + }; + top.min(last_top) + } +} + +/// `/TI` of a list box: on the widget, else on the field. +fn top_index(doc: &Document, wd: &Dict, field: Option<&Dict>) -> usize { + let ti = |d: &Dict| d.get(b"TI").and_then(|o| doc.resolve(o).as_int()); + ti(wd).or_else(|| field.and_then(ti)).and_then(|i| usize::try_from(i).ok()).unwrap_or(0) +} + +/// The rows of a list box whose field's `/DA` is `f_da`, in widget `wd`. +pub(crate) fn list_rows_of(doc: &Document, f_da: &str, field: Option<&Dict>, wd: &Dict) -> ListRows { + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da)); + let size = if da.size > 0.0 { da.size } else { 12.0 }; + ListRows { top: top_index(doc, wd, field), inset: 2.0 + border_width(doc, wd), row: size * 1.15, size } +} + +/// The rows of list box `f` as drawn in widget `w`. +pub fn list_rows(doc: &Document, f: &Field, w: &Widget) -> ListRows { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let fobj = doc.get(f.obj); + list_rows_of(doc, &f.da, fobj.as_dict(), &wd) +} + /// The appearance of a text or choice field's widget showing `values`. Characters its font can't /// hold come out as `?`; [`field_appearance_drawn`] says which. pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream { @@ -400,14 +487,13 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S let mut size = da.size; match f.kind { FieldKind::List => { - // Every option, one per line from the top; selected ones highlighted. - if size == 0.0 { - size = 12.0; - } - let line = size * 1.15; - let top = wd.get(b"TI").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).max(0) as usize; - let mut y = height - pad; - for (export, display) in f.options.iter().skip(top) { + // Every option, one per line from the top (from `/TI`); selected ones highlighted. + let fobj = doc.get(f.obj); + let rows = list_rows_of(doc, &f.da, fobj.as_dict(), &wd); + size = rows.size; + let line = rows.row; + let mut y = height - rows.inset; + for (export, display) in f.options.iter().skip(rows.top) { if y - line < 0.0 { break; } @@ -618,4 +704,23 @@ mod tests { assert_eq!(parse_da("garbage").font, "Helv"); assert_eq!(stroke_op("1 0 0 rg"), "1 0 0 RG"); } + + #[test] + fn list_rows_visibility_and_scrolling() { + let rows = ListRows { top: 0, inset: 2.0, row: 11.5, size: 10.0 }; + assert_eq!(rows.visible(46.0), 3); + assert_eq!(rows.option_at(0, 1.0), None); + assert_eq!(rows.option_at(0, 5.0), Some(0)); + assert_eq!(rows.option_at(0, 15.0), Some(1)); + assert_eq!(rows.option_at(2, 5.0), Some(2)); + + // When Tue (1) and Thu (3) are selected in 5 options with 3 visible rows: + assert_eq!(ListRows::top_showing(0, &[1, 3], 3, 5), 1); + // When staying within view: + assert_eq!(ListRows::top_showing(1, &[1, 2], 3, 5), 1); + // When selecting before current top: + assert_eq!(ListRows::top_showing(2, &[0], 3, 5), 0); + // When empty selection: + assert_eq!(ListRows::top_showing(0, &[], 3, 5), 0); + } } diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 0b60506c76..bb45f38d9f 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -657,6 +657,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel locked: false, hidden: false, rotation: 0, + list: None, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index e023ea209a..07f4c5484f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -103,6 +103,8 @@ pub struct Widget { pub hidden: bool, /// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0. pub rotation: i64, + /// List boxes: where the options are drawn, so a click can pick the one under the pointer. + pub list: Option, } /// A terminal form field. @@ -800,6 +802,7 @@ fn walk( locked: annot_flags & 128 != 0, hidden: annot_flags & (2 | 32) != 0, rotation: appearance::mk_rotation(doc, wd), + list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), }) }) .collect(); @@ -1053,6 +1056,16 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut // /I lists option indices in ascending order; /V keeps the caller's value order. // Every entry above is an integer, so this sorts by its numeric option index. indices.sort_unstable_by_key(Object::as_int); + // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as + // in other viewers: a pick below the last visible row would otherwise not show (#1524). + let top = match (f.kind, f.widgets.first()) { + (FieldKind::List, Some(w)) => w.list.map(|rows| { + let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; + let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); + (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) + }), + _ => None, + }; doc.update_dict(f.obj, |d| { match exports.as_slice() { [] => { @@ -1066,7 +1079,22 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut } else { d.set(b"I".to_vec(), Object::Array(indices)); } + if let Some((old, new)) = top + && old != new + { + d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); + } })?; + // A widget with its own `/TI` (read before the field's) scrolls too. + if let Some((old, new)) = top + && old != new + { + for w in f.widgets.iter().filter(|w| w.obj != f.obj) { + if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { + doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + } + } redraw(doc, f, &exports, scripts) } (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b99ca0c8a4..ef545cd433 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -607,6 +607,33 @@ fn choices_accept_exports_or_display_text() { assert!(matches!(set_value(&mut doc, "country", &FieldValue::Choice(vec!["ca".into(), "fr".into()])), Err(FormError::Invalid(_)))); } +#[test] +fn list_box_selection_scrolls_to_show_selected_options() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (days) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 700 200 746] /P 3 0 R >>" + .into(), + ]; + let mut doc = document(&objs); + // Setting value to Tue and Thu (indices 1 and 3) in a 46 pt high box (fits 3 rows). + // It should scroll /TI to 1 so Tue (1), Wed (2), Thu (3) are drawn and highlighted. + set_value(&mut doc, "days", &FieldValue::Choice(vec!["Tue".into(), "Thu".into()])).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let days = field(&all, "days"); + assert_eq!(days.value, ["Tue", "Thu"]); + let ti = doc.get(days.obj).as_dict().unwrap().get(b"TI").and_then(|o| o.as_int()); + assert_eq!(ti, Some(1), "TI scrolled to 1 so both selected options are visible"); + let appearance = ap(&doc, &days.widgets[0]); + assert!(appearance.contains("(Tue) Tj"), "Tue is drawn: {appearance}"); + assert!(appearance.contains("(Thu) Tj"), "Thu is drawn: {appearance}"); + assert_eq!(appearance.matches("0.6 0.75 0.86 rg").count(), 2, "both Tue and Thu highlighted"); +} + #[test] fn multiselect_choice_indices_are_sorted_without_reordering_values() { let mut forward_appearance = None; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8284c55aad..8020e868b2 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -1,18 +1,19 @@ //! Filling in forms on the page (execution plan M6; Acrobat fills fields in every viewing mode). //! //! Text fields open an in-place editor over the widget (Enter or clicking away commits, Escape -//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. Combo -//! and list boxes open a list of their options. Every change is one undoable engine edit. +//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. List +//! boxes are directly selectable in-place on the page (clicking picks an option; Ctrl-click +//! toggles on multi-select lists). Combo boxes open a list of their options. Every change is one +//! undoable engine edit. use std::collections::HashSet; use egui::{Color32, CornerRadius, Rect, Stroke, vec2}; -use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags}; +use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::theme::Tokens; -use crate::widgets; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Background of the in-place text field editor, the same in every theme. @@ -181,12 +182,48 @@ pub(crate) fn page_input( x.request_focus = true; } } - FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => { + FormFieldKind::Text | FormFieldKind::Combo => { if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { commit(view, form); view.forms.focus = Some(open_focus(f, wi)); } } + FormFieldKind::List => { + commit(view, form); + view.forms.focus = Some(open_focus(f, wi)); + let r = widget_rect(xf, info, page, w.rect); + let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) }; + let zoom = (r.height() as f64) / height_pt; + let y_pt = (p.y - r.min.y) as f64 / zoom; + let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 }); + let picked_indices: Vec = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect(); + let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len()); + if let Some(opt_idx) = rows.option_at(top, y_pt).filter(|&i| i < f.options.len()) { + let export = &f.options[opt_idx].0; + let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); + let shift = ui.input(|i| i.modifiers.shift); + let multi = f.has(field_flags::MULTI_SELECT); + let new_value = if multi && ctrl { + let mut picked = f.value.clone(); + if let Some(pos) = picked.iter().position(|x| x == export) { + picked.remove(pos); + } else { + picked.push(export.clone()); + } + FieldValue::Choice(picked) + } else if multi && shift && !f.value.is_empty() { + let anchor = f.value.last().and_then(|last| f.options.iter().position(|(e, _)| e == last)).unwrap_or(opt_idx); + let (min, max) = (anchor.min(opt_idx), anchor.max(opt_idx)); + let picked = f.options[min..=max].iter().map(|(e, _)| e.clone()).collect(); + FieldValue::Choice(picked) + } else { + FieldValue::Choice(vec![export.clone()]) + }; + if new_value != FieldValue::Choice(f.value.clone()) { + view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: new_value }); + } + } + } } FieldPointer::Owned } @@ -196,7 +233,6 @@ pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option { let f = form.iter().find(|f| f.name == focus.name)?; let value = match f.kind { FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()), - FormFieldKind::List if f.has(field_flags::MULTI_SELECT) && focus.picked != f.value => FieldValue::Choice(focus.picked.clone()), _ => return None, }; Some(Edit::SetFieldValue { name: f.name.clone(), value }) @@ -309,13 +345,11 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi); if focused { painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside); - } else { - if view.forms.unbaked.contains(&(f.name.clone(), page)) { - paint_unbaked_widget(painter, xf, f, w, r); - } - if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { - painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); - } + } + if view.forms.unbaked.contains(&(f.name.clone(), page)) { + paint_unbaked_widget(painter, xf, f, w, r); + } else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { + painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } } } @@ -352,7 +386,7 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) { match f.kind { - FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => { + FormFieldKind::Text | FormFieldKind::Combo => { let text = f.value.first().map(String::as_str).unwrap_or(""); painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4)); painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside); @@ -374,6 +408,38 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, p.galley(align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK); } } + FormFieldKind::List => { + painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4)); + painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside); + let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw }; + let zoom = (xf.rect.width() / unrotated_w.max(1.0)) as f64; + let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 }); + let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) }; + let picked_indices: Vec = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect(); + let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len()); + let font_size = (rows.size * zoom).clamp(6.0, 64.0) as f32; + let row_h = (rows.row * zoom) as f32; + let inset = (rows.inset * zoom) as f32; + let p = painter.with_clip_rect(r); + let mut y = r.min.y + inset; + for (export, display) in f.options.iter().skip(top) { + if y + row_h > r.max.y { + break; + } + let row_rect = Rect::from_min_size(egui::pos2(r.min.x + 1.0, y), egui::vec2(r.width() - 2.0, row_h)); + if f.value.contains(export) { + p.rect_filled(row_rect, CornerRadius::ZERO, Color32::from_rgb(0x99, 0xC0, 0xDC)); + } + p.text( + egui::pos2(r.min.x + 3.0, y + font_size * 0.15), + egui::Align2::LEFT_TOP, + display, + egui::FontId::proportional(font_size), + Color32::BLACK, + ); + y += row_h; + } + } FormFieldKind::CheckBox => { painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4)); painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside); @@ -502,8 +568,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f return view.pending_edit.take(); } } - FormFieldKind::Combo | FormFieldKind::List => { - let multi = f.kind == FormFieldKind::List && f.has(field_flags::MULTI_SELECT); + FormFieldKind::Combo => { let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) @@ -512,27 +577,14 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f ui.set_min_width(rect.width().max(160.0)); ui.label(egui::RichText::new(&f.name).small().color(t.text_faint)); egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - let Some(fx) = view.forms.focus.as_mut() else { return }; for (export, display) in &f.options { - let on = if multi { fx.picked.contains(export) } else { f.value.first() == Some(export) }; - if multi { - let mut c = on; - if ui.add(widgets::checkbox(&mut c, display)).changed() { - if c { - fx.picked.push(export.clone()); - } else { - fx.picked.retain(|x| x != export); - } - } - } else if ui.selectable_label(on, display).clicked() { + let on = f.value.first() == Some(export); + if ui.selectable_label(on, display).clicked() { edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) }); close = true; } } }); - if multi && ui.button(tl!("Done")).clicked() { - commit(view, form); - } }); }); let clicked_away = ctx.input(|i| i.pointer.any_click()) @@ -545,6 +597,35 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f next = Some(!shift); } else if clicked_away { commit(view, form); + close = true; + } + } + FormFieldKind::List => { + let (up, down, esc, tab, shift) = ctx.input_mut(|i| { + ( + // Consumed, so the arrows move the selection without also scrolling the page. + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.key_pressed(egui::Key::Escape), + i.key_pressed(egui::Key::Tab), + i.modifiers.shift, + ) + }); + if esc { + close = true; + } else if tab { + next = Some(!shift); + } else if (up || down) && !f.options.is_empty() { + let current_idx = f.value.last().and_then(|v| f.options.iter().position(|(e, _)| e == v)).unwrap_or(0); + let next_idx = if up { current_idx.saturating_sub(1) } else { (current_idx + 1).min(f.options.len().saturating_sub(1)) }; + if next_idx != current_idx && next_idx < f.options.len() { + let export = f.options[next_idx].0.clone(); + edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) }); + } + } + let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); + if clicked_away { + close = true; } } _ => close = true, diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 9cbed3ed7d..96a0a6a11e 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -872,6 +872,77 @@ fn the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced() { assert_eq!(h.query_all_by_label("orphan").count(), 1); } +#[test] +fn list_box_clicks_select_directly_and_ctrl_click_toggles_multi() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListOne) /Opt [(Red) (Green) (Blue)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListMulti) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 100 200 170] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("lists.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + + // Click row 1 (Green) in ListOne + let r_one = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "ListOne").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + let zoom_one = r_one.height() / 70.0; + let p_green = egui::pos2(r_one.center().x, r_one.min.y + 19.0 * zoom_one); + h.hover_at(p_green); + h.run_steps(1); + h.drag_at(p_green); + h.run_steps(1); + h.drop_at(p_green); + h.run_steps(3); + + assert_eq!(value(&h, "ListOne"), ["Green"], "clicking row 1 in ListOne selected Green"); + + // Click row 1 (Tue) in ListMulti + let r_multi = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "ListMulti").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + let zoom_multi = r_multi.height() / 70.0; + let p_tue = egui::pos2(r_multi.center().x, r_multi.min.y + 19.0 * zoom_multi); + h.hover_at(p_tue); + h.run_steps(1); + h.drag_at(p_tue); + h.run_steps(1); + h.drop_at(p_tue); + h.run_steps(3); + + assert_eq!(value(&h, "ListMulti"), ["Tue"], "clicking row 1 in ListMulti selected Tue"); + + // Ctrl/Cmd-click row 3 (Thu) in ListMulti + let p_thu = egui::pos2(r_multi.center().x, r_multi.min.y + 42.0 * zoom_multi); + h.hover_at(p_thu); + h.run_steps(1); + h.event(egui::Event::PointerMoved(p_thu)); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); + h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); + + assert_eq!(value(&h, "ListMulti"), ["Tue", "Thu"], "Ctrl-clicking Thu toggled it on, keeping Tue"); +} + /// Auto-size (`0 Tf`, inherited from the AcroForm) single-line and multiline fields, and a fixed 10 pt one. fn auto_size_form() -> Vec { pdf_from_objects(&[ From 9479561ffa996fb62dfa0793b04c3938c1e7e21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D8=AD=D8=B3=D9=86=20=D8=B3=D8=AF=D9=8A=D9=81=20=7C=20Hasa?= =?UTF-8?q?n=20Sdaif?= Date: Thu, 15 Oct 2026 10:57:18 +0300 Subject: [PATCH 12/19] =?UTF-8?q?Complete=20the=20Arabic=20(=D8=A7=D9=84?= =?UTF-8?q?=D8=B9=D8=B1=D8=A8=D9=8A=D8=A9)=20catalog:=20every=20missing=20?= =?UTF-8?q?row=20translated=20(#1559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Complete the Arabic (العربية) catalog: every missing row translated ar.tsv covered 2,325 of the interface rows; the newer catalogs (lt, cs, es and friends) had since grown to a 2,578-row union. This adds the 253 rows Arabic was missing, in the same clean-room Modern Standard Arabic and Acrobat-style vocabulary the file already uses: - digital-signature verification detail: every ECDSA/RSA/BER/DER reading note, timestamp-token and revocation sentence, the certificate inspector's sixteen fields (المُصدر، الرقم التسلسلي، صالح من/إلى…) and the key-usage purposes (عدم الجحود، تشفير المفاتيح…) - the accessibility checker's full vocabulary (وسوم، بنية المستند، النص البديل، خلايا الرأس…) and its report grades - the eight newer @plural rows with all six Arabic forms (صور محسّنة، بكسلات، نتائج…) and the calendar's weekday abbreviations - PDF/A output intents, combine/organize status lines, panel restore errors, and the small chrome (اختيار…، تنظيم، تقرير مفصل…). Same catalog, same lookups — Arabic now covers the union of every language's rows (2,578). * Refresh the ar.tsv sha256 in ATTRIBUTION.toml Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: hasan-sdaif <306196098+hasan-sdaif@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 2 +- crates/ui-egui/src/i18n/ar.tsv | 257 +++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 1 deletion(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 12d3e7820d..998fa13b8e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "79b7ecd15533611107700c32cf9faf6ce1041c41244e5fe82c783d964d0492f7" +sha256 = "9087b9d39cac0fb6ec3e6d886697b310686f20ba2c5cd86b9a80e23addd7de0c" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 041a1332a0..c7f460f548 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2399,6 +2399,263 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + +# Completion: the rows the newer catalogs (lt, cs, es …) grew — certificates, +# digital-signature verification detail, accessibility checks, calendar weekdays +# and the newer plural counts — same clean-room Arabic as the rest of the file. + on page {label} في الصفحة {label} + A font isn't embedded أحد الخطوط غير مدمج + A {} action isn't allowed إجراء {} غير مسموح + A {} annotation has additional actions للتعليق التوضيحي {} إجراءات إضافية + A {} annotation has no appearance stream للتعليق التوضيحي {} بلا مظهر متدفق + A {} annotation isn't set to print, or is hidden التعليق التوضيحي {} غير مضبوط للطباعة أو مخفي + Accessibility Report تقرير الوصول + Add the PDFs to combine أضف ملفات PDF للدمج + Added the first {n} PDFs in {folder} أضيفت أول {n} ملف PDF في {folder} + Always دائماً + Automatically تلقائياً + CRL Signing توقيع CRL + Certificate Signing توقيع الشهادات + Change Author تغيير المؤلف + Change Keywords تغيير الكلمات المفتاحية + Change Subject تغيير الموضوع + Change Title تغيير العنوان + Choose a digital ID. اختر معرّفاً رقمياً. + Choose a file… اختيار ملف… + Choose a folder… اختيار مجلد… + Choose… اختيار… + Color اللون + Comment تعليق + Comment and review التعليق والمراجعة + Copied to clipboard نُسخ إلى الحافظة + Copy to clipboard نسخ إلى الحافظة + Create folder إنشاء مجلد + Create links إنشاء ارتباطات + Creator المنشئ + Cut and stack needs Two-sided: Off. Print single-sided sheets. يحتاج القص والتكديس إلى «على الوجهين: إيقاف». اطبع أوراقاً أحادية الوجه. + Data Encipherment تشفير البيانات + Decipher Only فك التشفير فقط + Detailed Report تقرير مفصل + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile يُستخدم DeviceCMYK، لكن غرض إخراج PDF/A ليس ملف CMYK + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile يُستخدم DeviceRGB، لكن غرض إخراج PDF/A ليس ملف RGB + Digital Signature توقيع رقمي + Disabled معطّل + Document properties (⌘D) خصائص المستند (⌘D) + Drop PDFs or folders here, or choose them. أفلت ملفات PDF أو المجلدات هنا، أو اخترها. + Duplicate تكرار + Enabled ممكّن + Encipher Only التشفير فقط + English إنجليزي + Enter a URL أدخل URL + Enter a name. أدخل اسماً. + Enter a page number أدخل رقم صفحة + Enter the field names أدخل أسماء الحقول + Error خطأ + Every PDF in the folder كل ملف PDF في المجلد + Every PDF in the folder and the folders inside it كل ملف PDF في المجلد والمجلدات داخله + Field "{}" on page {p} has no description (tooltip) الحقل "{}" في الصفحة {p} بلا وصف (تلميح) + Filename اسم الملف + Flatten Fill & Sign تسطيح التعبئة والتوقيع + Folder مجلد + Font الخط + Font (unnamed) on page {p} doesn't map its characters to Unicode الخط (غير المسمى) في الصفحة {p} لا يربط محارفه بيونيكود + Font {} on page {p} doesn't map its characters to Unicode الخط {} في الصفحة {p} لا يربط محارفه بيونيكود + Form field on page {p} isn't tagged حقل النموذج في الصفحة {p} غير موسوم + Help… تعليمات… + H{n} follows an H{d}; headings should not skip levels يتبع H{n} عنوان H{d}؛ لا ينبغي للعناوين تخطي المستويات + Import استيراد + Information معلومات + Inspector المفتش + Key Agreement اتفاقية المفاتيح + Key Encipherment تشفير المفاتيح + Loading… يُحمَّل… + Move نقل + Never أبداً + New bookmark (⌘B) إشارة مرجعية جديدة (⌘B) + Next التالي + Next (⌘G) التالي (⌘G) + No PDFs found in {folder} لم يُعثر على ملفات PDF في {folder} + No document language is set لم تُحدد لغة المستند + No page has text: the document appears to be images of pages لا صفحة تحوي نصاً: يبدو المستند صوراً للصفحات + No results لا نتائج + Non-Repudiation عدم الجحود + On تشغيل + Only the document's owner can change its security (open it with the permissions password) مالك المستند وحده يستطيع تغيير أمانه (افتحه بكلمة مرور الأذونات) + Open a PDF (⌘O) فتح PDF‏ (⌘O) + Open folder فتح المجلد + Organize تنظيم + Page {n} صفحة {n} + Page {n} of {total} صفحة {n} من {total} + Page {p} doesn't tab in structure order الصفحة {p} لا تترتب بترتيب البنية + Page {p}: {n} untagged content item الصفحة {p}: {n} عنصر محتوى غير موسوم + Page {p}: {n} untagged content items الصفحة {p}: {n} عناصر محتوى غير موسومة + Page {} has additional actions للصفحة {} إجراءات إضافية + Panel layout could not be restored: {error} تعذرت استعادة تخطيط اللوحات: {error} + Panel move was not applied: {error} لم يُطبَّق نقل اللوحة: {error} + Paste لصق + PdfCraft can't check this signature yet: {}. لا يستطيع PdfCraft التحقق من هذا التوقيع بعد: {}. + Previous السابق + Previous (⇧⌘G) السابق (⇧⌘G) + Print (⌘P) طباعة (⌘P) + Producer المنتج + Refresh تحديث + Rendering… يُعرض… + Report created أُنشئ التقرير + Rev. {rev}: Certified by an unknown signer المراجعة {rev}: معتمد من موقّع مجهول + Rev. {rev}: Certified by {name} المراجعة {rev}: معتمد من {name} + Rev. {rev}: Signed by an unknown signer المراجعة {rev}: موقع من موقع مجهول + Rev. {rev}: Signed by {name} المراجعة {rev}: موقع من {name} + Rotate view clockwise (⇧⌘+) تدوير العرض مع عقارب الساعة (⇧⌘+) + Rule Name اسم القاعدة + Save (M4) حفظ (M4) + Save as {level} حفظ كـ {level} + Save changes? حفظ التغييرات؟ + Save file حفظ الملف + Search results نتائج البحث + Select all تحديد الكل + Signature is invalid التوقيع غير صالح + Signature is valid التوقيع صالح + Signature validity is unknown صلاحية التوقيع مجهولة + Signing time is from the clock on the signer's computer. وقت التوقيع من ساعة حاسوب الموقّع. + Still rendering — this page is unusually complex… لا يزال العرض جارياً — هذه الصفحة معقدة على غير العادة… + Success نجاح + System النظام + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. توقيع ECDSA ليس بصيغة DER القياسية (بايتات زائدة أو حشو أو بايت إشارة ناقص)؛ قُرئ مع ذلك. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. توقيع ECDSA هو قيمتا r وs الخام بدلاً من متتابعة DER؛ قُرئ مع ذلك. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. قيمة توقيع RSA ليست بطول معامل المفتاح (بايتات صفرية بادئة حُذفت أو أُضيفت)؛ قُرئ مع ذلك. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. لا يحوي DigestInfo لتوقيع RSA معلمة NULL (كذلك تكتبها الموقّعات القديمة)؛ قُرئ مع ذلك. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. شهادة {} لا تسمح بهذا العدد من شهادات CA تحتها (طول المسار)، لذا لا تُستخدم للضمان عن {}. + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. شهادة {} ليست شهادة CA يجوز لها إصدار الشهادات، لذا لا تُستخدم للضمان عن {}. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. شهادة {} لم تكن صالحة وقت التوقيع، لذا لا تُستخدم للضمان عن {}. + The checker found problems which may prevent the document from being fully accessible. وجد الفاحص مشكلات قد تمنع وصولاً كاملاً إلى المستند. + The document has been altered or corrupted since the signature was applied. عُدّل المستند أو تلف منذ تطبيق التوقيع. + The document has been altered or corrupted since the timestamp was applied. عُدّل المستند أو تلف منذ تطبيق الطابع الزمني. + The document has been altered since this signature was applied in ways it does not permit ({}). عُدّل المستند منذ تطبيق هذا التوقيع بطرق لا يسمح بها ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). عُدّل المستند منذ تطبيق هذا الطابع الزمني بطرق لا يسمح بها ({}). + The document has been modified since this signature was applied, but the changes are permitted ({}). عُدّل المستند منذ تطبيق هذا التوقيع، لكن التغييرات مسموحة ({}). + The document has been modified since this timestamp was applied, but the changes are permitted ({}). عُدّل المستند منذ تطبيق هذا الطابع الزمني، لكن التغييرات مسموحة ({}). + The document has no tags (structure tree) لا وسوم في المستند (شجرة البنية) + The document has no title لا عنوان للمستند + The document isn't marked as tagged (MarkInfo Marked) المستند غير معلّم كموسوم (MarkInfo Marked) + The document isn't tagged, so none of its content is المستند غير موسوم، لذا لا شيء من محتواه موسوم + The document's embedded revocation information shows that the signer's certificate has not been revoked. تُظهر معلومات الإلغاء المدمجة في المستند أن شهادة الموقّع لم تُلغَ. + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. رمز الطابع الزمني المدمج صالح وسلطته موثوقة؛ الوقت الموثوق هو {}. + The font {} isn't embedded الخط {} غير مدمج + The metadata doesn't identify the file as {} البيانات الوصفية لا تحدد الملف كـ {} + The password is incorrect. كلمة المرور غير صحيحة. + The password must have at least 6 characters. يجب أن تحوي كلمة المرور 6 محارف على الأقل. + The passwords do not match. كلمتا المرور غير متطابقتين. + The security settings don't allow screen readers to read the text إعدادات الأمان لا تسمح لقارئات الشاشة بقراءة النص + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. خوارزمية التوقيع وخوارزمية الهضم في SignerInfo تسمّيان هضمين مختلفين؛ صُنع التوقيع بخوارزمية SignerInfo. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. يحمل التوقيع طابعاً زمنياً غير موثق ({}): سلطة الطابع الزمني ليست في قائمة شهاداتك الموثوقة، لذا يُستخدم وقت التوقيع الذي زعمه الموقّع بدلاً منه. + The signature contents are not hexadecimal. محتويات التوقيع ليست سداسية عشرية. + The signature could not be checked ({}). تعذر التحقق من التوقيع ({}). + The signature could not be read ({}). تعذرت قراءة التوقيع ({}). + The signature could not be verified ({}). تعذر التثبت من التوقيع ({}). + The signature has no valid byte range. لا نطاق بايتات صالحاً للتوقيع. + The signature includes an embedded timestamp. يتضمن التوقيع طابعاً زمنياً مدمجاً. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. التوقيع مرمّز BER (أطوال غير محددة) لا DER؛ قُرئ بتساهل. + The signature uses the legacy adbe.x509.rsa_sha1 format. يستخدم التوقيع تنسيق adbe.x509.rsa_sha1 القديم. + The signature value does not match the signer's certificate: the signature is corrupt. قيمة التوقيع لا تطابق شهادة الموقّع: التوقيع تالف. + The signature's byte range does not exclude exactly its contents. نطاق بايتات التوقيع لا يستبعد محتوياته بالضبط. + The signature's byte range does not match the file. نطاق بايتات التوقيع لا يطابق الملف. + The signature's timestamp token could not be verified ({}). تعذر تثبت رمز الطابع الزمني للتوقيع ({}). + The signature's timestamp token does not cover the signature value. رمز الطابع الزمني للتوقيع لا يغطي قيمة التوقيع. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. المحتوى الموقّع OCTET STRING مركّب (مقسم) كما يسمح BER؛ قُرئ مع ذلك. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). شهادة الموقّع لا تسمح بالتواقيع الرقمية (استخدام مفتاحها لا يضم توقيعاً رقمياً ولا عدم جحود). + The signer's certificate has been revoked ({}). أُلغيت شهادة الموقّع ({}). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). لشهادة الموقّع امتدادات حرجة لا يتعرّف عليها PdfCraft ({}). + The signer's certificate is not in the signature. شهادة الموقّع ليست في التوقيع. + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). شهادة الموقّع لم تُصدر لتوقيع المستندات (استخدامها الممتد للمفتاح لا يضم غرض توقيع مستندات). + The signer's certificate was not valid at the time of signing. شهادة الموقّع لم تكن صالحة وقت التوقيع. + The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. هوية الموقّع مجهولة لأنها ليست في قائمة شهاداتك الموثوقة ولا أي من شهاداتها الأم موثوقة. + The signer's identity is valid. هوية الموقّع صحيحة. + The timestamp authority's certificate was not valid at the time of timestamping. شهادة سلطة الطابع الزمني لم تكن صالحة وقت التوقيت. + The timestamp contents are not hexadecimal. محتويات الطابع الزمني ليست سداسية عشرية. + The timestamp has no valid byte range. لا نطاق بايتات صالحاً للطابع الزمني. + The timestamp token could not be read ({}). تعذرت قراءة رمز الطابع الزمني ({}). + The timestamp token is valid and its authority is trusted. رمز الطابع الزمني صالح وسلطته موثوقة. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. رمز الطابع الزمني صالح، لكن سلطة الطابع الزمني ليست في قائمة شهاداتك الموثوقة. + The timestamp's byte range does not exclude exactly its contents. نطاق بايتات الطابع الزمني لا يستبعد محتوياته بالضبط. + The timestamp's byte range does not match the file. نطاق بايتات الطابع الزمني لا يطابق الملف. + The window shows the file name, not the title (Initial View > Show) تُظهر النافذة اسم الملف لا العنوان (العرض الأولي > إظهار) + Theme السمة + There is no folder to save the digital ID in. لا مجلد لحفظ المعرّف الرقمي فيه. + This document has not been modified since this signature was applied. لم يُعدَّل المستند منذ تطبيق هذا التوقيع. + This document has not been modified since this timestamp was applied. لم يُعدَّل المستند منذ تطبيق هذا الطابع الزمني. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. يستخدم هذا التوقيع RIPEMD-160، خوارزمية هضم قديمة بطول 160 بت لم تعد موصى بها للتواقيع. لا يزال يصح، لكن لا ينبغي الاعتماد عليه. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. يستخدم هذا التوقيع SHA-1، خوارزمية هضم ضعيفة: أُثبتت تصادمات فيها. لا يزال يصح لأن مستندات كثيرة وقّعت قبل سنوات تستخدمه، لكن لا ينبغي الاعتماد عليه. + Tools الأدوات + Unsigned signature field حقل توقيع غير موقع + Unsigned signature field: {name} حقل توقيع غير موقع: {name} + Warning تحذير + Zoom in (⌘+) تكبير (⌘+) + Zoom in / out (also pinch or ⌘-scroll) تكبير / تصغير (أيضاً قرص أو ⌘-تمرير) + Zoom out (⌘−) تصغير (⌘−) + alternate text hides an annotation inside it النص البديل يخفي تعليقاً توضيحياً داخله + alternate text on an element with no page content نص بديل على عنصر بلا محتوى صفحة + comments تعليقات + document security store مخزن أمان المستند + document structure بنية المستند + form fill تعبئة نموذج + its alternate text is inside another element's and will never be read نصه البديل داخل نص عنصر آخر ولن يُقرأ أبداً + links ارتباطات + metadata بيانات وصفية + no alternate text لا نص بديل + no header cells (TH) لا خلايا رأس (TH) + no summary لا ملخصاً + not in a TR ليس في TR + not in a Table, THead, TBody or TFoot ليس في Table أو THead أو TBody أو TFoot + not in an L (list) ليس في L (قائمة) + not in an LI (list item) ليس في LI (عنصر قائمة) + of من + other changes تغييرات أخرى + page content محتوى الصفحة + page {n} is out of range (1–{count}) الصفحة {n} خارج النطاق (1–{count}) + pages added or removed صفحات أُضيفت أو حُذفت + rows have between {min} and {max} columns للصفوف بين {min} و{max} عموداً + the first heading is an H{n}; headings should not skip levels العنوان الأول H{n}؛ لا ينبغي للعناوين تخطي المستويات + the signed version could not be read تعذرت قراءة النسخة الموقعة + {n} pages and no bookmarks {n} صفحات ولا إشارات مرجعية + {page} is not a page number or label {page} ليس رقم صفحة أو تسمية + {ty} element on page {p}: {what} عنصر {ty} في الصفحة {p}: {what} + {ty} element: {what} عنصر {ty}: {what} + {who}, certificate issued by an unknown issuer. {who}، شهادة صادرة من مُصدر مجهول. + {who}, certificate issued by {issuer}. {who}، شهادة صادرة من {issuer}. + {} annotation on page {p} isn't tagged التعليق التوضيحي {} في الصفحة {p} غير موسوم + {} annotations aren't allowed التعليقات التوضيحية {} غير مسموحة + … and {n} more … و{n} أخرى +@plural Delete {n} field|Delete {n} fields حذف {n} حقل|حذف {n} حقل|حذف {n} حقلين|حذف {n} حقول|حذف {n} حقلاً|حذف {n} حقل +@plural {n} byte|{n} bytes {n} بايت|{n} بايت|{n} بايتان|{n} بايتات|{n} بايتاً|{n} بايت +@plural {n} comment|{n} comments {n} تعليق|{n} تعليق|{n} تعليقان|{n} تعليقات|{n} تعليقاً|{n} تعليق +@plural {n} file|{n} files {n} ملف|{n} ملف|{n} ملفان|{n} ملفات|{n} ملفاً|{n} ملف +@plural {n} image optimized|{n} images optimized {n} صورة محسّنة|{n} صورة محسّنة|{n} صورتان محسّنتان|{n} صور محسّنة|{n} صورة محسّنة|{n} صورة محسّنة +@plural {n} item discarded|{n} items discarded {n} عنصر مهمل|{n} عنصر مهمل|{n} عنصران مهملان|{n} عناصر مهملة|{n} عنصراً مهملًا|{n} عنصر مهمل +@plural {n} pixel|{n} pixels {n} بكسل|{n} بكسل|{n} بكسلان|{n} بكسلات|{n} بكسلاً|{n} بكسل +@plural {n} result|{n} results {n} نتيجة|{n} نتيجة|{n} نتيجتان|{n} نتائج|{n} نتيجةً|{n} نتيجة +calendar weekday Fr جمعة +calendar weekday Mo إثنين +calendar weekday Sa سبت +calendar weekday Su أحد +calendar weekday Th خميس +calendar weekday Tu ثلاثاء +calendar weekday We أربعاء +certificate Basic constraints القيود الأساسية +certificate Intended usage الاستخدام المقصود +certificate Issued by صادر من +certificate Issued to مُصدر إلى +certificate Issuer المُصدر +certificate Key usage استخدام المفتاح +certificate Public key المفتاح العام +certificate SHA-1 digest بصمة SHA-1 +certificate SHA-256 digest بصمة SHA-256 +certificate Self-signed ذاتي التوقيع +certificate Serial number الرقم التسلسلي +certificate Valid from صالح من +certificate Valid to صالح إلى +certificate Validity ends تنتهي الصلاحية +certificate Validity starts تبدأ الصلاحية +certificate Version الإصدار Turn upside down (180°) اقلب رأسًا على عقب (180°) 4 x 6 in label (101.6 x 152.4 mm) ملصق 4 × 6 بوصة (101.6 × 152.4 مم) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. يقص كل صفحة إلى المنطقة المطبوعة، ويديرها لتصبح مستقيمة ويلائمها مع الورقة دون تمديد. ملصق واحد لكل ورقة. From cccd55fab258a80502e36b41592ad3609531f883 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:01:45 +0200 Subject: [PATCH 13/19] M1: edit finds the pages a damaged file's readers find (#1664) * M1: edit finds the pages a damaged file's readers find (fixes #1656) * M1: rebuild a lost page tree only when the reader shows the same pages, and refuse edits to signed rebuilt files (#1656) --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/pdfcraft-cli/tests/edit.rs | 96 +++++++++++++++++++++++++++++++++ crates/cos/src/document.rs | 45 ++++++++++++---- crates/engine/src/lib.rs | 34 +++++++++++- crates/engine/src/tests.rs | 44 +++++++++++++++ crates/organize/src/lib.rs | 38 ++++++++++++- 5 files changed, 244 insertions(+), 13 deletions(-) diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs index 2864dcfb3c..b35b71dddc 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/edit.rs @@ -239,6 +239,102 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() { } } +/// Two pages whose catalog points at a page tree object the file does not have (#1656). The +/// reader finds the pages by scanning, in file order, so `edit` must work on the same pages. +/// Object 3 is written before object 2, so file order and object numbers disagree. +fn fixture_without_page_tree() -> Vec { + let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(); + let objs: Vec<(usize, Vec)> = vec![ + (1, b"<< /Type /Catalog /Pages 6 0 R >>".to_vec()), + (3, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>".to_vec()), + (2, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 300 200] /Contents 5 0 R >>".to_vec()), + (4, stream("0 0 m 10 10 l S")), + (5, stream("0 0 m 20 20 l S")), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0; objs.len()]; + for (num, o) in &objs { + offsets[num - 1] = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n").as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn a_document_whose_page_tree_is_missing_edits_the_pages_the_reader_shows() { + let input = tmp("no-page-tree", "in.pdf"); + std::fs::write(&input, fixture_without_page_tree()).unwrap(); + let before = pdfcraft_render::inspect(std::sync::Arc::new(fixture_without_page_tree()), None).unwrap(); + assert_eq!(before.pages.iter().map(|p| (p.width, p.height)).collect::>(), [(200.0, 300.0), (300.0, 200.0)]); + for (name, args, expected) in [ + // Width and height are as displayed: the turned page reads 200 x 300. + ("rotate.pdf", ["--rotate", "2:90"], &[(200.0, 300.0, 0), (200.0, 300.0, 90)][..]), + ("delete.pdf", ["--delete", "1"], &[(300.0, 200.0, 0)][..]), + ] { + let output = tmp("no-page-tree", name); + let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()]; + all.extend(args); + let out = Command::new(BIN).args(&all).output().unwrap(); + assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap(); + let pages: Vec<_> = info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect(); + assert_eq!(pages, expected, "{name}"); + } +} + +/// Like [`fixture_without_page_tree`], but the scanning reader shows different pages from the +/// page objects: it skips the blank 200 x 200 page (no `/Contents`) and shows the Text +/// annotation (which has `/Contents`) as a page. No page tree may be rebuilt from such a file. +fn fixture_whose_scan_disagrees() -> Vec { + let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 9 0 R >>".to_vec(), + b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>".to_vec(), + b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 200 200] >>".to_vec(), + b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [7 0 R] >>".to_vec(), + stream("0 0 m 10 10 l S"), + stream("0 0 m 20 20 l S"), + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (hello) >>".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn a_page_tree_is_not_rebuilt_when_the_reader_shows_other_pages() { + let input = tmp("scan-disagrees", "in.pdf"); + std::fs::write(&input, fixture_whose_scan_disagrees()).unwrap(); + let shown = pdfcraft_render::inspect(std::sync::Arc::new(fixture_whose_scan_disagrees()), None).unwrap(); + assert_eq!(shown.pages.len(), 3); + assert!(shown.pages.iter().all(|p| p.width != 200.0), "the reader must not show the blank page"); + let output = tmp("scan-disagrees", "out.pdf"); + let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), "--delete", "2"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(String::from_utf8_lossy(&out.stderr), "pdfcraft-cli: the document's page tree lists no pages: the file is damaged\n"); + assert!(!output.exists(), "no page may be deleted"); +} + /// A page past the end names its own option and the page count left by the earlier options (#1550). #[test] fn a_page_past_the_end_after_earlier_edits_names_that_option() { diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 46b59abfca..a0b1f56eeb 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1355,13 +1355,9 @@ impl Document { None => return Ok(None), } } - Err(e) => { - if sections.is_empty() { - return Err(e); - } - log.push(format!("older cross-reference section at {off} is unreadable: {e}")); - break; - } + // An unreadable older section loses every object only it locates: the + // materialized reader logs it and the file is rebuilt from object headers (#1656). + Err(e) => return Err(e), }; revisions.push(Revision { xref_offset: off, is_stream }); let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; @@ -1649,11 +1645,12 @@ impl Document { self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? } Err(e) => { - if revisions.is_empty() { - return Err(e); + // An unreadable older section loses every object only it locates (the page + // tree, often): rebuild from object headers, as readers do (#1656). + if !revisions.is_empty() { + log.push(format!("older cross-reference section at {off} is unreadable: {e}")); } - log.push(format!("older cross-reference section at {off} is unreadable: {e}")); - break; + return Err(e); } }; revisions.push(Revision { xref_offset: off, is_stream }); @@ -2188,6 +2185,32 @@ mod tests { } } + #[test] + fn objects_only_an_unreadable_older_section_locates_are_found_by_scanning() { + // The newest section lists the catalog only; its /Prev points past the end of the file, + // so the page tree is reachable only by scanning object headers, as readers do (#1656). + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, body) in + [(1, "<< /Type /Catalog /Pages 2 0 R >>"), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), (3, "<< /Type /Page /Parent 2 0 R >>")] + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n0 2\n0000000000 65535 f \n{:010} 00000 n \ntrailer\n<< /Root 1 0 R /Size 4 /Prev 999999 >>\nstartxref\n{xref}\n%%EOF\n", + offsets[0] + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.get(ObjRef::new(2, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Pages".as_slice())); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Page".as_slice())); + assert!(doc.repair_log().iter().any(|l| l.contains("999999")), "{:?}", doc.repair_log()); + } + #[test] fn self_referential_length_does_not_overflow() { let bytes = build( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7a858581fa..01f162ae7d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -875,6 +875,12 @@ fn is_placeholder_title(title: &str) -> bool { /// What is displayed: the working file, plus (in memory only, never saved) the appearances /// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]). /// The working file is unchanged, and so is everything that reads it (saving, signatures, …). +/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. +fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) +} + fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances)) else { return bytes.clone(); @@ -3452,6 +3458,31 @@ impl Session { }, (_, cos) => (bytes, info, cos), }; + // The catalog leads to no page, but the reader found pages by scanning: gather the page + // objects into a page tree as one more revision, or page edits address none of them + // (#1656). Kept only when the reader then shows the same pages, and never on a signed + // document, where writing the repair could rewrite the file under its signatures. + let (bytes, info, cos) = match cos { + Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { + let mut work = cos.clone(); + let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); + let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { + Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), + _ => None, + }; + match rebuilt { + Some((b, i, mut c)) => { + for line in cos.repair_log() { + c.note_repair(line.clone()); + } + c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); + (b, i, Ok(Ok(c))) + } + None => (bytes, info, Ok(Ok(cos))), + } + } + cos => (bytes, info, cos), + }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); @@ -3690,8 +3721,9 @@ impl Session { } // Signed documents are only ever saved incrementally: an edit that needs a full rewrite // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && rewrites(&next) && !rewrites(&editor.cos) { + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { return Err(EditError::SignedRewrite(edit.label().to_string())); } // What this edit drew with `?` (a value or comment text its font can't draw, stored as diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d61c82d9d4..ed90eea411 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5126,6 +5126,50 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() { assert!(d.page_image_copy_file(0, 1).is_err()); } +/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656). +fn fixture_without_page_tree() -> Vec { + let bytes = fixture(2); + let catalog = b"<< /Type /Catalog /Pages 2 0 R >>"; + let at = bytes.windows(catalog.len()).position(|w| w == catalog).unwrap(); + let mut out = bytes.clone(); + out[at..at + catalog.len()].copy_from_slice(b"<< /Type /Catalog /Pages 9 0 R >>"); + out +} + +#[test] +fn a_recovered_page_tree_opens_unmodified_and_edits_the_pages_shown() { + let mut s = Session::new(); + let id = s.open("damaged.pdf", None, Arc::new(fixture_without_page_tree()), None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(!doc.editor.as_ref().unwrap().cos.is_modified(), "the repair is a revision of the opened file, not an unsaved edit"); + assert!(doc.repair_log().iter().any(|l| l.contains("page tree is missing")), "{:?}", doc.repair_log()); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.iter().map(|p| p.rotation).collect::>(), [0, 90]); +} + +#[test] +fn a_signed_document_rebuilt_from_object_headers_refuses_edits() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap(); + let opts = SignOptions { page: 1, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed, None).unwrap(); + // A broken startxref: the file opens only by rebuilding from object headers, and any save + // of it is a full rewrite. + let mut bytes = s.get(id).unwrap().bytes.as_ref().clone(); + let sx = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10; + let end = sx + bytes[sx..].iter().position(|b| !b.is_ascii_digit()).unwrap(); + bytes.splice(sx..end, b"1".iter().copied()); + let id = s.open("damaged.pdf", None, Arc::new(bytes), None).unwrap(); + assert!(s.get(id).unwrap().is_signed()); + assert!(s.get(id).unwrap().editor.as_ref().unwrap().cos.revisions().is_empty(), "the fixture must be rebuilt"); + assert!(matches!(s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])), Err(EditError::SignedRewrite(_)))); + assert!(!s.get(id).unwrap().dirty); +} + /// #1324: marking a figure decorative rewrites that figure only. The header, added text and an /// unknown entry on the original stream stay attached to the page. This lives in the engine tests /// because edit and a11y are the same layer and cannot depend on each other. diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 1d8a1e92d9..3b2873abec 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -11,7 +11,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry}; mod boxes; mod dedupe; @@ -148,8 +148,44 @@ fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeE Ok(()) } +/// Gather the document's `/Type /Page` objects into a new page tree when the catalog leads to no +/// page (its `/Pages` is missing or points at a lost object). Readers then find the pages by +/// scanning the file, in file order, and edits must address those same pages. Returns how many +/// pages were gathered; 0 leaves the document unchanged. +pub fn recover_page_tree(doc: &mut Document) -> Result { + if walk(doc).is_ok_and(|pages| !pages.is_empty()) { + return Ok(0); + } + let catalog = doc.root().ok_or(OrganizeError::NoPageTree)?; + let position = |num: u32| match doc.xref_entry(num) { + Some(XrefEntry::InFile { offset, .. }) => (offset, 0), + Some(XrefEntry::InStream { stream, index }) => match doc.xref_entry(stream) { + Some(XrefEntry::InFile { offset, .. }) => (offset, index), + _ => (u64::MAX, index), + }, + _ => (u64::MAX, 0), + }; + let mut found: Vec<((u64, u32), ObjRef)> = doc + .scan_objects() + .filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Page"))) + .map(|(r, _)| (position(r.num), r)) + .collect(); + if found.is_empty() { + return Ok(0); + } + found.sort(); + let root = doc.add(Dict::new()); + doc.update_dict(catalog, |d| d.set(b"Pages".to_vec(), Object::Ref(root)))?; + let order: Vec<(ObjRef, Dict)> = found.into_iter().map(|(_, r)| (r, Dict::new())).collect(); + rebuild(doc, &order)?; + Ok(order.len()) +} + fn check(indices: &[usize], n: usize) -> Result<(), OrganizeError> { match indices.iter().find(|i| **i >= n) { + // A viewer still shows pages a lost page tree leaves out (#1656): "page 1 does not + // exist" would contradict it. + Some(_) if n == 0 => Err(OrganizeError::Invalid("the document's page tree lists no pages: the file is damaged".into())), Some(i) => Err(OrganizeError::NoSuchPage(*i)), None => Ok(()), } From ac69c29823e785264ccb4091dcc9d6d739203722 Mon Sep 17 00:00:00 2001 From: tomasmark79 <44719504+tomasmark79@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:02:11 +0200 Subject: [PATCH 14/19] M14: Fix(nixos): discover installed UI fallback fonts on NixOS (#1553) * M14: discover installed UI fallback fonts through Fontconfig Find existing fallback faces in configured directories, including Nix store paths. Bound and cache discovery while preserving candidate names, glyph checks and the screenshot opt-out. Assisted-by: OpenAI Codex * M14: keep the drop-dispatch regression deterministic Select inline document preparation in the existing drop-dispatch test, matching the validation fix used by both Wayland branches. Full workspace, strict Clippy and wasm gates pass. Assisted-by: OpenAI Codex --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 10 + ROADMAP.md | 2 + crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/src/system_fonts.rs | 46 ++- crates/ui-egui/src/system_fonts/fontconfig.rs | 329 ++++++++++++++++++ docs/development.md | 14 + docs/roadmap.md | 1 + 7 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/system_fonts/fontconfig.rs diff --git a/Cargo.lock b/Cargo.lock index 49e949bff2..5dcaf7b1b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2088,6 +2088,15 @@ dependencies = [ "serde", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -4347,6 +4356,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "fontconfig-parser", "getrandom 0.4.3", "image", "js-sys", diff --git a/ROADMAP.md b/ROADMAP.md index c2cd0f8819..efa6dc7704 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. +- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged. - **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged. - **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ddab6a223f..aa9fe54f66 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -45,6 +45,10 @@ arboard = { version = "3.6", default-features = false, features = ["image-data"] # Waits for native file pickers off the UI thread (already in the tree through wgpu). pollster = "1.0" +[target.'cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))'.dependencies] +# Discover configured font directories without linking libfontconfig or running a subprocess. +fontconfig-parser = "0.5.8" + [target.'cfg(target_os = "macos")'.dependencies] # Where the pointer is while files are dragged over the window (winit doesn't say): safe AppKit # calls only. All three are already in the tree through arboard and rfd. diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index e64533ee12..606fa923dc 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -13,6 +13,9 @@ use std::sync::{Arc, OnceLock}; use egui::FontData; +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod fontconfig; + /// Larger files are not read: a font path is still untrusted input. const MAX_BYTES: u64 = 32 << 20; /// Chinese system faces are big collections (PingFang, STHeiti), so they get a higher cap. @@ -291,7 +294,37 @@ fn windows_fonts() -> PathBuf { } fn unix_fonts(files: &'static [&'static str]) -> Vec { - ["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| files.iter().map(move |f| Path::new(dir).join(f))).collect() + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + static INSTALLED: OnceLock> = OnceLock::new(); + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + // Read once per process, and not at all when installed fonts are turned off. + let installed = INSTALLED + .get_or_init(|| { + if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") { + return Vec::new(); + } + fontconfig::font_files(&fontconfig::config_path()) + }) + .as_slice(); + #[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))] + let installed: &[PathBuf] = &[]; + unix_candidates(files, installed) +} + +fn unix_candidates(files: &[&str], installed: &[PathBuf]) -> Vec { + let mut paths: Vec<_> = + ["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| files.iter().map(move |f| Path::new(dir).join(f))).collect(); + // Fontconfig may point into /nix/store or a user's font directory. Keep the existing + // candidate names and their preference order; this does not select arbitrary fonts. + for file in files { + let name = Path::new(file).file_name(); + for path in installed.iter().filter(|p| p.file_name() == name) { + if !paths.contains(path) { + paths.push(path.clone()); + } + } + } + paths } fn read(path: &Path, probe: char, max: u64) -> Option> { @@ -318,6 +351,17 @@ fn face_with(bytes: &[u8], c: char) -> Option { mod tests { use super::*; + #[test] + fn configured_candidates_preserve_names_order_and_existing_paths() { + let names = ["noto/NotoSansArabic-Regular.ttf", "truetype/dejavu/DejaVuSans.ttf"]; + let noto = PathBuf::from("/custom/fonts/NotoSansArabic-Regular.ttf"); + let dejavu = PathBuf::from("/nix/store/font/share/fonts/truetype/DejaVuSans.ttf"); + let installed = [dejavu.clone(), PathBuf::from("/custom/fonts/SourceHanSans.ttf"), noto.clone(), dejavu.clone()]; + let mut expected = unix_candidates(&names, &[]); + expected.extend([noto, dejavu]); + assert_eq!(unix_candidates(&names, &installed), expected); + } + #[test] fn broken_and_missing_files_are_skipped() { assert_eq!(face_with(b"", PROBE), None); diff --git a/crates/ui-egui/src/system_fonts/fontconfig.rs b/crates/ui-egui/src/system_fonts/fontconfig.rs new file mode 100644 index 0000000000..dbec74eaea --- /dev/null +++ b/crates/ui-egui/src/system_fonts/fontconfig.rs @@ -0,0 +1,329 @@ +//! Discover font files in configured directories, including Nix store paths. +//! +//! Only the existing fallback filenames are selected by the caller. Font bytes are still +//! checked for size and glyph coverage there; no font is copied or embedded. + +use std::collections::HashSet; +use std::io::Read as _; +use std::path::{Path, PathBuf}; + +// A configured directory may contain unrelated files or symlink cycles. Bound the walk. +const MAX_ENTRIES: usize = 32_768; +const MAX_DEPTH: usize = 16; +const MAX_CONFIGS: usize = 256; +const MAX_CONFIG_BYTES: u64 = 1024 * 1024; + +#[derive(Default)] +struct Directories { + dirs: Vec, + seen: HashSet, + entries: usize, +} + +impl Directories { + fn include(&mut self, path: &Path, depth: usize) { + if depth > MAX_DEPTH || self.seen.len() >= MAX_CONFIGS { + return; + } + let Ok(canonical) = path.canonicalize() else { return }; + if self.seen.contains(&canonical) { + return; + } + if canonical.is_dir() { + self.seen.insert(canonical); + let Ok(entries) = std::fs::read_dir(path) else { return }; + let mut configs = Vec::new(); + for entry in entries { + if self.entries >= MAX_ENTRIES { + break; + } + self.entries += 1; + let Ok(entry) = entry else { continue }; + // Fontconfig directory includes accept digit-prefixed *.conf only. + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.as_bytes().first().is_some_and(u8::is_ascii_digit) && name.ends_with(".conf") && entry.path().is_file() { + configs.push(entry.path()); + } + } + configs.sort(); + for config in configs { + self.include(&config, depth + 1); + } + return; + } + if !canonical.is_file() { + return; + } + self.seen.insert(canonical); + let Ok(file) = std::fs::File::open(path) else { return }; + let mut bytes = Vec::new(); + if file.take(MAX_CONFIG_BYTES + 1).read_to_end(&mut bytes).is_err() || bytes.len() as u64 > MAX_CONFIG_BYTES { + return; + } + let Ok(xml) = String::from_utf8(bytes) else { return }; + let parts = match fontconfig_parser::parse_config_parts(&xml) { + Ok(parts) => parts, + Err(error) => { + log::debug!("interface font directories: {}: {error}", path.display()); + return; + } + }; + for part in parts { + use fontconfig_parser::{ConfigPart, DirPrefix}; + match part { + ConfigPart::Dir(dir) if self.dirs.len() < MAX_ENTRIES => self.dirs.push(dir.calculate_path(path)), + ConfigPart::ResetDirs => self.dirs.clear(), + ConfigPart::Include(mut include) => { + // The dependency's default include prefix hardcodes /etc/fonts. + // Resolve relative to this file, including nested and FONTCONFIG_PATH configs. + if include.prefix == DirPrefix::Default { + include.prefix = DirPrefix::Relative; + } + self.include(&include.calculate_path(path), depth + 1); + } + _ => {} + } + } + } +} + +pub(super) fn config_path() -> PathBuf { + let file = std::env::var_os("FONTCONFIG_FILE").map_or_else(|| PathBuf::from("fonts.conf"), PathBuf::from); + if file.is_absolute() { + return file; + } + let dir = + std::env::var_os("FONTCONFIG_PATH").and_then(|paths| std::env::split_paths(&paths).next()).unwrap_or_else(|| PathBuf::from("/etc/fonts")); + dir.join(file) +} + +pub(super) fn font_files(config_path: &Path) -> Vec { + // Use the dependency's XML parser, but own include resolution and directory filtering. + // Missing or malformed configuration must not prevent the ordinary fallback paths. + let mut config = Directories::default(); + config.include(config_path, 0); + let mut pending: Vec<_> = config.dirs.into_iter().map(|dir| (dir, 0)).collect(); + let mut seen = HashSet::new(); + let mut files = Vec::new(); + let mut remaining = MAX_ENTRIES; + while let Some((dir, depth)) = pending.pop() { + let Ok(canonical) = dir.canonicalize() else { continue }; + if !seen.insert(canonical) { + continue; + } + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for entry in entries { + if remaining == 0 { + files.sort(); + return files; + } + remaining -= 1; + let Ok(entry) = entry else { continue }; + let path = entry.path(); + // Follow links to fonts and directories, but visit each directory only once. + if path.is_dir() { + if depth < MAX_DEPTH { + pending.push((path, depth + 1)); + } + } else if path.extension().is_some_and(|ext| ext == "ttf" || ext == "ttc") && path.is_file() { + files.push(path); + } + } + } + files.sort(); + files +} + +#[cfg(test)] +mod tests { + use super::*; + + // Fixtures contain only configuration and dummy files, never copied system fonts. + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-fontconfig-{}-{n}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + Self(dir) + } + + fn config(&self, name: &str, body: &str) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, format!("{body}")).unwrap(); + path + } + + fn isolated(&self, test: &str) { + // Environment selection is process-global: never mutate it in a parallel harness. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &format!("system_fonts::fontconfig::tests::{test}"), "--nocapture"]) + .env("FONTCONFIG_PATH", &self.0) + .env("FONTCONFIG_FILE", "fonts.conf") + .env("PDFCRAFT_FONTCONFIG_TEST_ROOT", &self.0) + .output() + .unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); + } + + fn font(&self) -> PathBuf { + let dir = self.0.join("fonts"); + std::fs::create_dir(&dir).unwrap(); + let font = dir.join("DejaVuSans.ttf"); + std::fs::write(&font, b"dummy font for directory discovery").unwrap(); + font + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn relative_includes_follow_each_including_config_under_fontconfig_path() { + if let Some(root) = std::env::var_os("PDFCRAFT_FONTCONFIG_TEST_ROOT") { + let root = PathBuf::from(root); + assert_eq!(config_path(), root.join("fonts.conf")); + assert_eq!(font_files(&config_path()), vec![root.join("fonts/DejaVuSans.ttf")]); + return; + } + let fixture = Fixture::new(); + fixture.font(); + std::fs::create_dir(fixture.0.join("conf.d")).unwrap(); + std::fs::create_dir(fixture.0.join("nested")).unwrap(); + fixture.config("fonts.conf", "conf.d"); + fixture.config("conf.d/10-fonts.conf", "../nested/next.conf"); + fixture.config("nested/next.conf", &format!("{}", fixture.0.join("fonts").display())); + fixture.isolated("relative_includes_follow_each_including_config_under_fontconfig_path"); + } + + #[test] + fn included_directories_ignore_disabled_backup_and_non_numbered_files() { + if let Some(root) = std::env::var_os("PDFCRAFT_FONTCONFIG_TEST_ROOT") { + let root = PathBuf::from(root); + assert_eq!(font_files(&config_path()), vec![root.join("fonts/DejaVuSans.ttf")]); + return; + } + for name in ["99-reset.conf.disabled", "99-reset.conf.bak", "reset.conf"] { + let fixture = Fixture::new(); + fixture.font(); + std::fs::create_dir(fixture.0.join("conf.d")).unwrap(); + fixture.config("fonts.conf", &format!("{}", fixture.0.join("conf.d").display())); + fixture.config("conf.d/10-fonts.conf", &format!("{}", fixture.0.join("fonts").display())); + fixture.config(&format!("conf.d/{name}"), ""); + fixture.isolated("included_directories_ignore_disabled_backup_and_non_numbered_files"); + } + } + + #[test] + fn numbered_configs_keep_lexical_reset_order_and_include_cycles_terminate() { + let fixture = Fixture::new(); + let font = fixture.font(); + std::fs::create_dir(fixture.0.join("conf.d")).unwrap(); + let config = fixture.config("fonts.conf", "conf.d"); + fixture.config("conf.d/10-reset.conf", ""); + let external = fixture.config("external.conf", &format!("{}../fonts.conf", fixture.0.join("fonts").display())); + std::os::unix::fs::symlink(external, fixture.0.join("conf.d/20-fonts.conf")).unwrap(); + assert_eq!(font_files(&config), vec![font]); + fixture.config("conf.d/30-reset.conf", ""); + assert!(font_files(&config).is_empty()); + } + + #[test] + fn oversized_configs_are_skipped_without_losing_other_includes() { + let fixture = Fixture::new(); + let font = fixture.font(); + std::fs::create_dir(fixture.0.join("conf.d")).unwrap(); + let config = fixture.config("fonts.conf", "conf.d"); + std::fs::write(fixture.0.join("conf.d/10-large.conf"), vec![b' '; (MAX_CONFIG_BYTES + 1) as usize]).unwrap(); + fixture.config("conf.d/20-fonts.conf", &format!("{}", fixture.0.join("fonts").display())); + assert_eq!(font_files(&config), vec![font]); + } + + #[test] + fn deep_include_chains_are_bounded() { + let fixture = Fixture::new(); + fixture.font(); + for depth in 0..=MAX_DEPTH + 1 { + let body = if depth > MAX_DEPTH { + format!("{}", fixture.0.join("fonts").display()) + } else { + format!("depth-{}.conf", depth + 1) + }; + fixture.config(&format!("depth-{depth}.conf"), &body); + } + assert!(font_files(&fixture.0.join("depth-0.conf")).is_empty()); + } + + #[test] + fn wide_include_directories_are_bounded() { + let fixture = Fixture::new(); + fixture.font(); + std::fs::create_dir(fixture.0.join("conf.d")).unwrap(); + let config = fixture.config("fonts.conf", "conf.d"); + for n in 0..MAX_CONFIGS { + fixture.config(&format!("conf.d/{n:04}-empty.conf"), ""); + } + fixture.config("conf.d/9999-fonts.conf", &format!("{}", fixture.0.join("fonts").display())); + assert!(font_files(&config).is_empty()); + } + + #[test] + fn included_configuration_finds_fonts_outside_standard_directories() { + let fixture = Fixture::new(); + let fonts = fixture.0.join("nix-store-font/share/fonts/truetype"); + std::fs::create_dir_all(&fonts).unwrap(); + let font = fonts.join("DejaVuSans.ttf"); + std::fs::write(&font, b"not parsed during discovery").unwrap(); + std::fs::write(fonts.join("README.txt"), b"not a font").unwrap(); + let included = fixture.config("nixos.conf", &format!("{}", fixture.0.join("nix-store-font").display())); + let config = fixture.config("fonts.conf", &format!("{}", included.display())); + assert_eq!(font_files(&config), vec![font]); + } + + #[test] + fn missing_and_malformed_configuration_are_skipped() { + let fixture = Fixture::new(); + assert!(font_files(&fixture.0.join("missing.conf")).is_empty()); + let path = fixture.0.join("broken.conf"); + std::fs::write(&path, "").unwrap(); + assert!(font_files(&path).is_empty()); + } + + #[test] + fn symlinked_fonts_are_found_and_directory_cycles_terminate() { + let fixture = Fixture::new(); + let fonts = fixture.0.join("fonts"); + std::fs::create_dir(&fonts).unwrap(); + let original = fixture.0.join("original.ttf"); + std::fs::write(&original, b"dummy").unwrap(); + let linked = fonts.join("DejaVuSans.ttf"); + std::os::unix::fs::symlink(&original, &linked).unwrap(); + std::os::unix::fs::symlink(&fonts, fonts.join("loop")).unwrap(); + std::os::unix::fs::symlink(fixture.0.join("missing.ttf"), fonts.join("broken.ttf")).unwrap(); + let config = fixture.config("fonts.conf", &format!("{}{}", fonts.display(), fonts.display())); + assert_eq!(font_files(&config), vec![linked]); + } + + #[test] + fn deeply_nested_directories_are_bounded() { + let fixture = Fixture::new(); + let mut dir = fixture.0.join("fonts"); + std::fs::create_dir(&dir).unwrap(); + let shallow = dir.join("DejaVuSans.ttf"); + std::fs::write(&shallow, b"dummy").unwrap(); + for _ in 0..MAX_DEPTH + 1 { + dir.push("nested"); + std::fs::create_dir(&dir).unwrap(); + } + std::fs::write(dir.join("DejaVuSans.ttf"), b"dummy").unwrap(); + let config = fixture.config("fonts.conf", &format!("{}", fixture.0.join("fonts").display())); + assert_eq!(font_files(&config), vec![shallow]); + } +} diff --git a/docs/development.md b/docs/development.md index 1d5bb80951..e599effc72 100644 --- a/docs/development.md +++ b/docs/development.md @@ -133,3 +133,17 @@ copies retain their current preferences locations. | `PDFCRAFT_BUILD_LABEL` | Build time: a label for a local or patched build (letters, digits, inner dots), shown after the version in `--version`, About and the log (`0.7.1 (titlebar)`). `packaging/linux/package.sh` also puts it in the file names and the .rpm/.deb release (`1.titlebar`), which upgrades over the release of the same version | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) | | `PDFCRAFT_SYSTEM_FONTS` | `0` stops the desktop app from using an installed font for characters its embedded fonts lack (`cargo xtask screenshots` sets it) | +| `FONTCONFIG_FILE` | On Linux/Unix, the Fontconfig configuration file used to discover installed UI fallback fonts (default: `/etc/fonts/fonts.conf`); relative filenames are resolved under `FONTCONFIG_PATH` | +| `FONTCONFIG_PATH` | Directory for a relative `FONTCONFIG_FILE` (default: `/etc/fonts`; the first entry of a search path is used) | + +On Linux/Unix, the UI's existing fallback filenames are also searched in Fontconfig's configured +directories and their subdirectories. This finds fonts in Nix store paths and user font directories +without invoking `fc-match` or linking libfontconfig. The scan is cached and bounded; missing or +malformed configuration leaves the ordinary `/usr/share/fonts` and `/usr/local/share/fonts` +candidates available. Font size and glyph-coverage checks still apply. This only reads installed +fonts for the interface; it does not embed them into PDFs or packages. + +Relative includes resolve beside the config that contains them. Included directories load only +digit-prefixed `*.conf` files, in lexical order; backups and disabled files are ignored. The scan +limits each config to 1 MiB, visits at most 256 config files/directories, and bounds include depth +to 16 and directory entries to 32,768. Cyclic includes are skipped. diff --git a/docs/roadmap.md b/docs/roadmap.md index e6a5ce0397..e570c1ae1b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,6 +132,7 @@ total matches the parity estimate. | 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
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
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
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, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. 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 Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | 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, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. 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 XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | | 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 | | 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, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. 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 | From 53b9bc1059d7aa001e360614c655ff0cbae1467f Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:02:35 +0200 Subject: [PATCH 15/19] M6: preserve carriage returns in form CSV exports (#1563) * M6: preserve carriage returns in exported form CSV Quote CR in names and values so reimport cannot discard a trailing CR as a record terminator. Verify eleven combinations through saved-PDF round trips. Co-Authored-By: Codex * Keep main's ROADMAP status line Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: MMexicann Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 ++ crates/xfdf/src/lib.rs | 2 +- crates/xfdf/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ docs/roadmap.md | 3 ++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index efa6dc7704..332ba2733e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. - **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. - **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged. - **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged. @@ -487,6 +488,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | | 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index e271364d9d..3c3ebe3f07 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -592,7 +592,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index fbb2fb21a0..4e876468cd 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -346,6 +346,41 @@ fn one_field() -> Document { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() } +#[test] +fn csv_exports_preserve_carriage_returns_in_names_and_values() { + for (name, value, expected) in [ + ("Name", "plain", "Name\nplain\n"), + ("Name", "Tail\r", "Name\n\"Tail\r\"\n"), + ("Name", "Alpha\rBeta", "Name\n\"Alpha\rBeta\"\n"), + ("Name", "\rLead", "Name\n\"\rLead\"\n"), + ("Name", "\r", "Name\n\"\r\"\n"), + ("Name", "Alpha\r\nBeta", "Name\n\"Alpha\r\nBeta\"\n"), + ("Name", "Alpha\nBeta", "Name\n\"Alpha\nBeta\"\n"), + ("Name", "comma,\r", "Name\n\"comma,\r\"\n"), + ("Name", "\"quoted\"\r", "Name\n\"\"\"quoted\"\"\r\"\n"), + ("Name", "before\r,\"after\"", "Name\n\"before\r,\"\"after\"\"\"\n"), + ("Na\rme", "both\r", "\"Na\rme\"\n\"both\r\"\n"), + ] { + let mut src = one_field(); + let field = fields(&src)[0].obj; + src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap(); + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + assert_eq!(values(&src), [(name.to_string(), vec![value.to_string()])]); + let csv = export_data(&src, Format::Csv); + + let mut dst = Document::open(source).unwrap(); + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + let report = import(&mut dst, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty(), "{report:?}"); + let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(values(&reopened), [(name.to_string(), vec![value.to_string()])]); + assert_eq!(csv, expected, "CSV must quote CR as well as LF, commas and quotes"); + } +} + /// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the /// value used to split it and only the text before the comma was imported. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index e570c1ae1b..5bdefc3753 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -116,7 +116,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | @@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | | 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | | 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | From e62d3db31bb35088610b0e64e127b2539aaa7f8a Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:03:00 +0200 Subject: [PATCH 16/19] M6: keep AcroForm XML field tags within the NCName grammar (#1588) Constrain the existing normalization to XML1.0 name characters while preserving valid tags and original PDF names through xfdf:original. Verify Unicode names, prefixes and normalization collisions with saved-PDF round trips. Co-authored-by: MMexicann Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 ++ crates/xfdf/src/lib.rs | 26 ++++++++++++++++--- crates/xfdf/src/tests.rs | 54 ++++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 1 + 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 332ba2733e..e71d88dc76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. - **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. - **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. - **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged. @@ -488,6 +489,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | | 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. | diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 3c3ebe3f07..50326c4e71 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -558,10 +558,30 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); out } -/// The XML element name for a field name (letters, digits, `_`, `-`, `.`). +/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. +/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar +fn xml_name_start(c: char) -> bool { + matches!( + c, + '_' | 'A'..='Z' | 'a'..='z' + | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' + | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' + | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' + | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' + | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' + | '\u{10000}'..='\u{EFFFF}' + ) +} + +fn xml_name_char(c: char) -> bool { + xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') +} + +/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. fn xml_name(name: &str) -> String { - let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); - if !s.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { + let mut s: String = + name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); + if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { s.insert(0, '_'); } s diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 4e876468cd..d244df1adf 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -292,6 +292,60 @@ trailer\n<< /Root 1 0 R >>\n%%EOF\n"; assert_eq!((parent("cycle-a").as_deref(), parent("cycle-b").as_deref()), (Some("cycle-b"), Some("cycle-a"))); } +#[test] +fn xml_field_names_remain_valid_and_round_trip_without_collisions() { + assert_eq!(xml_name(".Value"), "_.Value"); + let names = [ + ("Area²", "Area_"), + ("Area_", "Area_"), + ("Aµ", "A_"), + ("µRate", "_Rate"), + ("²Rate", "_Rate"), + ("½Amount", "_Amount"), + ("1Rate", "_1Rate"), + ("Café", "Café"), + ("Δείγμα", "Δείγμα"), + ("姓名", "姓名"), + ("٣Value", "_٣Value"), + ("\u{345}Rate", "_\u{345}Rate"), + ("A\u{345}", "A\u{345}"), + ("Alpha_1-2", "Alpha_1-2"), + ("ns:value", "ns_value"), + ("-Value", "_-Value"), + ]; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, (name, _)) in names.iter().enumerate() { + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some(name)).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("value-{index}"))).unwrap(); + } + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + let want = values(&src); + for format in [Format::Xml, Format::Xdp] { + let text = export_data(&src, format); + let xml = roxmltree::Document::parse(&text).unwrap(); + let children: Vec<_> = xml.root_element().children().filter(|n| n.is_element()).collect(); + assert_eq!(children.len(), names.len()); + for (child, (original, tag)) in children.iter().zip(names) { + assert_eq!(child.tag_name().name(), tag); + assert_eq!(child.attribute(("http://ns.adobe.com/xfdf-transition/", "original")), (original != tag).then_some(original)); + } + assert!(!src.is_modified(), "export must not change the source PDF"); + assert_eq!(src.bytes().as_slice(), source.as_slice()); + + let mut dst = Document::open(source.clone()).unwrap(); + for (name, _) in names { + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + } + let report = import(&mut dst, text.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len()); + assert!(report.rejected.is_empty(), "{report:?}"); + let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(values(&reopened), want, "{format:?}: sanitized names must preserve distinct original fields"); + } +} + #[test] fn form_data_as_xml_csv_and_text() { let src = filled(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 5bdefc3753..3148823ea5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | | 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | From ab45a1855f43d31cc7c8633b307f5e6876bf7291 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Thu, 15 Oct 2026 05:03:25 -0300 Subject: [PATCH 17/19] Keep annotations readable under reading colours (#1548) (#1557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(render): keep annotations readable under reading colours (#1548) Reading colours remapped annotation paints as line art: the yellow highlight fill is luminance-light, so it was mapped toward the dark background, and Multiply then darkened the remapped-white words under it into invisibility. Two changes in hayro: - The /Annots loop in interpret_page is bracketed by a new begin_annotations/end_annotations Device signal (patch 26), and the renderer's reading-colours mapping leaves annotation paints alone. Reading colours replace document colours, never the colours a comment was given — the Acrobat model. - The page background is painted inside the page transparency group (patch 27), as the initial backdrop, so a Multiply highlight blends with the background too. With the background outside the group the band kept its own flat colour and the words vanished into it. White pages are unaffected: multiplying with white is the identity. Regression test reading_colors_keep_highlight_annotations_readable covers both annotation paths (/AP and the no-/AP /QuadPoints fallback) under WHITE_ON_BLACK; parity entry a11y.replace-page-colors updated with the behaviour and evidence. Co-Authored-By: GLM * contributors: credit @edurbs as Eduardo Soares The contributor's own entry, chosen by them (real and display name). Co-Authored-By: GLM * #1557: record the vendored hayro patches in vendor/README.md Main already uses hayro-interpret patches (26)-(28), so the annotation hooks become hayro-interpret (29) and the renderer's parts hayro (17) and (18). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: GLM Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 5 +++ crates/render/src/raster.rs | 50 +++++++++++++++++++++ parity/acrobat-features.toml | 4 +- vendor/README.md | 12 ++--- vendor/hayro-interpret/src/device.rs | 7 +++ vendor/hayro-interpret/src/interpret/mod.rs | 4 ++ vendor/hayro/src/lib.rs | 14 ++++-- vendor/hayro/src/renderer.rs | 23 ++++++++++ 8 files changed, 108 insertions(+), 11 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 268df23a2f..da3cfc6a77 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,6 +25,11 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.edurbs] +real_name = "Eduardo Soares" +display_name = "Eduardo Soares" +sync_github_name = false + [people.jcl-bit] real_name = "James Locke" display_name = "James Locke" diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 78d6788e2f..17afd82c78 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -5617,6 +5617,56 @@ trailer << /Root 1 0 R >> assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white"); } + /// Reading colours replace *document* colours, never annotation colours (issue #1548): a + /// highlight keeps its `/C`, so on a dark background the content under it shows through the + /// Multiply blend in the highlight's colour instead of being darkened into invisibility. + /// Both annotation paths are covered: the `/AP` appearance stream and the no-`/AP` + /// `/QuadPoints` fallback. + #[test] + fn reading_colors_keep_highlight_annotations_readable() { + let content = "0 g 100 40 10 20 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 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] /Contents 4 0 R /Annots [5 0 R] >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Annot /Subtype /Highlight /Rect [15 35 120 65] /QuadPoints [15 65 120 65 15 35 120 35] /C [1 1 0] {{annot}} >> endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [15 35 120 65] /Length 24 >> stream +0 0 1 rg 15 35 50 30 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let render = |annot: &str| { + let mut r = PageRenderer::new( + Arc::new(pdf.replace("{annot}", annot).into_bytes()), + RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }, + ); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{annot}: {:?}", p.error); + p + }; + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + + // No /AP: the page's black rectangle is remapped to white (document line art), the + // yellow fill is not, so Multiply leaves the content under the quad yellow — readable. + let p = render(""); + assert_eq!(px(&p, 5, 5), vec![0, 0, 0, 255], "the page clears to the background"); + assert_eq!(px(&p, 105, 50), vec![255, 255, 0, 255], "content under the quad shows through in the highlight's colour"); + assert_eq!(px(&p, 30, 50), vec![0, 0, 0, 255], "the band itself multiplies into the dark background, as in Acrobat"); + assert_eq!(px(&p, 150, 50), vec![0, 0, 0, 255], "outside the quad"); + + // With /AP the appearance's blue is an annotation colour too and stays blue instead of + // being remapped towards white by its luminance. + let p = render("/AP << /N 6 0 R >>"); + assert_eq!(px(&p, 30, 50), vec![0, 0, 255, 255], "the appearance keeps its own colour"); + assert_eq!(px(&p, 90, 50), vec![0, 0, 0, 255], "outside the appearance's own drawing"); + } + /// A print-preview request (`PRINT_TAG`) renders the document's true colours even though /// the document's config carries reading colours: a preview's job is fidelity to the /// printout, which is imposed from the uncoloured document. Covers both render paths — diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7f6407500b..cfe560a940 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9119,8 +9119,8 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"] -notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)." +evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"] +notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)." [[feature]] id = "a11y.reflow-tagged" diff --git a/vendor/README.md b/vendor/README.md index 3cc347c2c3..4af80f7059 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,13 +9,13 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours` -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text` -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled` -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height` diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 72d5a98098..36c64a23fd 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -66,6 +66,13 @@ pub trait Device<'a> { fn actual_text(&mut self, _text: &[u8]) {} /// Called at the end of a marked content sequence (EMC). fn end_marked_content(&mut self) {} + /// PdfCraft patch (29): the page's annotations are about to be drawn (until + /// `end_annotations`), as page content finishes. Devices can use this to treat their + /// paints as annotation colours rather than document colours — the reading-colours + /// setting replaces document colours only, never the colours a comment was given. + fn begin_annotations(&mut self) {} + /// PdfCraft patch (29): the page's annotations are done; what follows is page content. + fn end_annotations(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 931b3388ce..fe08fc284b 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -175,6 +175,9 @@ pub fn interpret_page<'a>( if context.settings.render_annotations && let Some(annot_arr) = page.raw().get::>(ANNOTS) { + // PdfCraft patch (29): bracket the annotations so devices can tell their paints from + // the document's own (the reading-colours setting recolours the document, not comments). + device.begin_annotations(); for annot in annot_arr.iter::>() { let flags = annot.get::(F).unwrap_or(0); @@ -275,6 +278,7 @@ pub fn interpret_page<'a>( draw_highlight_without_appearance(&annot, context, device); } } + device.end_annotations(); } } diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index def102b429..43381ab679 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -254,6 +254,16 @@ pub fn render_into<'a>( interpreter_settings.preview, ); + // PdfCraft patch (18): the page renders as one transparency group with the background + // painted *inside* it, as the initial backdrop. A group isolates what it wraps from what + // was painted before it, so with the background outside, a Multiply highlight blended + // only with the group's transparent backdrop and stayed its own colour — over a dark + // reading-colours background the whole quad became one flat bar and the words under it + // vanished. Inside the group the highlight multiplies with the background too: the words + // keep the highlight's colour and the band between them takes the background (white + // backgrounds are unaffected — multiplying with white is the identity). + device.push_transparency_group(1.0, None, BlendMode::Normal); + device.ctx.set_paint(render_settings.bg_color); device .ctx @@ -265,12 +275,10 @@ pub fn render_into<'a>( fill: FillRule::NonZero, }); - device.push_transparency_group(1.0, None, BlendMode::Normal); interpret_page(page, &mut state, &mut device); - device.pop_transparency_group(); - device.pop_clip_path(); + device.pop_transparency_group(); let mut resources = vello_cpu::Resources::default(); device.ctx.render_to_buffer( diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 45bb2350bf..0619b4c6dd 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -432,6 +432,9 @@ pub(crate) struct Renderer { /// PdfCraft patch: when set, solid text and line-art paint colours are remapped /// while rendering (the "reading colours" view setting). color_replacement: Option, + /// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their + /// colours — reading colours replace document colours, never annotation colours. + in_annotations: bool, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -466,6 +469,7 @@ impl Renderer { Self { ctx: RenderContext::new_with(width, height, settings), color_replacement, + in_annotations: false, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: cache.outline_cache.clone(), @@ -573,6 +577,7 @@ impl Renderer { // PdfCraft patch: this renders an image's alpha mask, which must keep its // original values, so it is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1023,6 +1028,11 @@ impl Renderer { let Some(replacement) = self.color_replacement else { return paint.clone(); }; + // PdfCraft patch (17): annotations keep the colours they were given; reading colours + // replace document colours only (issue #1548). + if self.in_annotations { + return paint.clone(); + } match paint { Paint::Color(c) => { let mapped = replacement.map(c.to_rgba().to_rgba8(), is_text); @@ -1154,6 +1164,7 @@ impl Renderer { // PdfCraft patch: a pattern cell is painted as one artwork, so its // content is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, cur_mask: None, inside_pattern: true, soft_mask_cache: HashMap::default(), @@ -1469,6 +1480,7 @@ impl<'a> Device<'a> for Renderer { // coverage values, so its content is never remapped by // the reading-colours setting. color_replacement: None, + in_annotations: false, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1613,6 +1625,16 @@ impl<'a> Device<'a> for Renderer { } } + /// PdfCraft patch (17): the reading-colours mapping is suspended for annotations. + fn begin_annotations(&mut self) { + self.in_annotations = true; + } + + /// PdfCraft patch (17): back to document content; reading colours apply again. + fn end_annotations(&mut self) { + self.in_annotations = false; + } + fn draw_rect( &mut self, rect: &Rect, @@ -1754,6 +1776,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei // PdfCraft patch: a soft mask must keep its original luminance values, so its // content is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(), From cdf44d7999fe80263feeae63ba1a5ade0d2d0c1d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:03:49 +0200 Subject: [PATCH 18/19] Comments panel: page groups fold from their chevron (#1614) * Comments panel: page groups fold from their chevron (fixes #1554) * Comments panel: translate the fold-all buttons in every catalog "Collapse all pages" and "Expand all pages" were missing from the 21 catalogs, so the covers_ui_literals tests failed; the catalogs' hashes are re-pinned in ATTRIBUTION.toml. * Add the Vietnamese entries for Collapse/Expand all pages; refresh catalog hashes Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 ++++++++++----------- crates/ui-egui/src/comments.rs | 2 + crates/ui-egui/src/comments_panel.rs | 59 +++++++++++++++++++++++----- crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/tests/comments.rs | 55 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 27 files changed, 175 insertions(+), 33 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 998fa13b8e..3f3791f719 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 = "82a6d3b48da75024afd3261e2c2a30d2f8b45da5448b6018ad5b35d489d2c7bb" +sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" # 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 = "1d8134aad27f74186b0df2f6257ee51b0955a69597de5ca86cf4013ab98f7da9" +sha256 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223" [[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 = "1a5a956aabe5746a0768497cb4e6568622a5ab70b8d429f745c33dda977a8fca" +sha256 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2" [[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 = "5a29fe0c99e7116a2aab2387be553bb95b59ce450afc2546a04949a68dcf4f31" +sha256 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536" [[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 = "09c3f837fefbe0774e9b21a40d74b93ab3671bb143f141e8cb212c92d91e4733" +sha256 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7" [[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 = "31ae2ecf486e132ad085b4ec415416263ee1e5e7461ec4dd4e778bf628fefcf9" +sha256 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679" [[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 = "fb7f392472e9ffbd7f8492d52b14c8e6bdd28655415869eefbcd1af43178d4c2" +sha256 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a" [[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 = "85576c18721874113d92c8ee477e5a92d43d898f042ec5104bc3ee18336ef2a7" +sha256 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69" [[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 = "1f65ce25add0ffd6dbb9ea25e1bac1f78604265547b1995a7a43e573e303fc7b" +sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" [[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 = "c1a5a6d120bec8eaa8e60a5eb7f805aaf846f8ae0facf04a5a61da769dc6672c" +sha256 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5" [[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 = "06cfe4f2dcdf970aa4269658930885c2536dfc3a1b5391fccc626232fd01b3de" +sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" [[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 = "ca6e03bf5a37b39c7e7870e2c52f9c9fd88ec13324296926f0c2853675b155c3" +sha256 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363" [[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 = "b0dcd01ae69d702740e6732b77b7ae8d192c4f789be9d3c5128bfa745ce07527" +sha256 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643" [[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 = "75b42fcb55a4518ba2057a2dfe29cd4b3718c26a0e1a3c83101a19201d9542ce" +sha256 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e" [[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 = "c9986f1b5a19f8fa3b431229e56bae73715d138e6507b0b7c314483029109585" +sha256 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578" [[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 = "ef3156aab103af5bd99efac444a3e085ef50f9a5786c8c4f34aa5f384d2f028e" +sha256 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf" [[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 = "13ee22f546e669cbde325343c3c1474c89932bc49342f02da3059047865007f0" +sha256 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2" [[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 = "9087b9d39cac0fb6ec3e6d886697b310686f20ba2c5cd86b9a80e23addd7de0c" +sha256 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25" [[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 = "116784d4457a2cff2018e2652cc537a924f365012409547cbbaac3409c315c22" +sha256 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339" [[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 = "581a77647ccd0f002decdb430bcda1a7dfa90d3d2948fe6a12b33251e5edd318" +sha256 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5" [[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 = "949a3e933149eaf4fad17fb6711ec38b134858d0c434bdf1c38125bf7ead9855" +sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3" [[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 = "5ca7e04c805f8ae8bee1b81f6c5e6263dddd1673ef003ec5c90c1b87b4bfd9ba" +sha256 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 3bdbf5a2c0..5a708c14f6 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -481,6 +481,8 @@ pub struct CommentView { pub hidden_checks: Vec, /// How the panel orders comments. pub sort: SortBy, + /// Pages whose group is folded in the panel (when sorted by page). + pub collapsed_pages: Vec, /// Comment Properties was asked for from the panel: (page, index). pub props_request: Option<(usize, usize)>, /// Make Current Properties Default was asked for: (page, index). diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 32ea870b0a..c5fbbd0ce3 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -199,6 +199,13 @@ pub(crate) fn show( SortBy::Date => None, } }; + // A comment selected elsewhere (on the page, or just added) opens its folded group, or the + // card it should scroll to would not be drawn. + if view.comments.reveal + && let Some((p, _)) = view.comments.selected + { + view.comments.collapsed_pages.retain(|&c| c != p); + } let mut page = usize::MAX; let mut group: Option = None; for a in roots { @@ -215,18 +222,38 @@ pub(crate) fn show( } else if a.page != page { page = a.page; let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count(); + let open = !view.comments.collapsed_pages.contains(&page); ui.add_space(6.0); - ui.horizontal(|ui| { - ui.add(icons::image("chevron-down", 14.0, t.text_muted)); - ui.label( - egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)])) - .font(theme::semibold(12.5)) - .color(t.text_muted), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small())); - }); + let clicked = ui + .horizontal(|ui| { + let chevron = if open { "chevron-down" } else { "chevron-right" }; + let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked(); + let label = ui.add( + egui::Label::new( + egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)])) + .font(theme::semibold(12.5)) + .color(t.text_muted), + ) + .sense(Sense::click()), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()) + }); + toggle || label.clicked() + }) + .inner; + if clicked { + if open { + view.comments.collapsed_pages.push(page); + } else { + view.comments.collapsed_pages.retain(|&c| c != page); + } + } ui.add_space(2.0); } + if sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page) { + continue; + } let thread = replies_of(a); if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { edit = Some(e); @@ -519,6 +546,20 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.close(); } } + // Only the page sort has folding groups. + if cv.sort == SortBy::Page { + ui.separator(); + if ui.button(tl!("Collapse all pages")).clicked() { + cv.collapsed_pages = info.annotations.iter().map(|a| a.page).collect(); + cv.collapsed_pages.sort_unstable(); + cv.collapsed_pages.dedup(); + ui.close(); + } + if ui.button(tl!("Expand all pages")).clicked() { + cv.collapsed_pages.clear(); + ui.close(); + } + } ui.separator(); for (id, label) in [ ("comment.hide_all", if hidden { tl!("Show all comments") } else { tl!("Hide all comments") }), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c7f460f548..01bb1679e4 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2279,6 +2279,8 @@ key Space مسافة Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها. Expand all توسيع الكل Collapse all طي الكل + Collapse all pages طي كل الصفحات + Expand all pages توسيع كل الصفحات Remove page إزالة الصفحة Remove {n} pages إزالة {n} صفحات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 5334c39e9b..8f2fea70e0 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2219,6 +2219,8 @@ key Space Интервал Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат. Expand all Разгъни всички Collapse all Свий всички + Collapse all pages Свий всички страници + Expand all pages Разгъни всички страници Remove page Премахни страницата Remove {n} pages Премахни {n} страници diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9e62f7f348..bad5c497e8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2257,6 +2257,8 @@ certificate Subject Subjekt List view Zobrazení seznamu Expand all Rozbalit vše Collapse all Sbalit vše + Collapse all pages Sbalit všechny stránky + Expand all pages Rozbalit všechny stránky Files are combined in the order shown. Drag them to change it. Soubory se slučují v zobrazeném pořadí. Pořadí změníte přetažením. Pages: {k} of {n} Stránky: {k} z {n} Check the pages Zkontrolujte stránky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 29439a186a..b5baed7ba1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2261,6 +2261,8 @@ key Space Leertaste Switch to List view to change the pages it takes. Wechsle zur Listenansicht, um die übernommenen Seiten zu ändern. Expand all Alle ausklappen Collapse all Alle einklappen + Collapse all pages Alle Seiten einklappen + Expand all pages Alle Seiten ausklappen Remove page Seite entfernen Remove {n} pages {n} Seiten entfernen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 36a1d99095..57de6eef07 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1619,6 +1619,8 @@ Switch to List view to change the pages it takes. Μεταβείτε στην προβολή λίστας για να αλλάξετε ποιες σελίδες θα συμπεριληφθούν. Expand all Ανάπτυξη όλων Collapse all Σύμπτυξη όλων + Collapse all pages Σύμπτυξη όλων των σελίδων + Expand all pages Ανάπτυξη όλων των σελίδων Remove {n} pages Αφαίρεση {n} σελίδων Add an image field Προσθήκη πεδίου εικόνας Add a date field Προσθήκη πεδίου ημερομηνίας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 62cbf95fe3..548daf0caa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2397,6 +2397,8 @@ key Space Espacio Switch to List view to change the pages it takes. Cambia a la vista de lista para cambiar las páginas que se incluyen. Expand all Expandir todo Collapse all Contraer todo + Collapse all pages Contraer todas las páginas + Expand all pages Expandir todas las páginas Remove page Quitar página Remove {n} pages Quitar {n} páginas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 208f5d2c25..c082789bc1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2260,6 +2260,8 @@ key Space Espace Switch to List view to change the pages it takes. Passez à la vue en liste pour modifier les pages prises. Expand all Tout développer Collapse all Tout réduire + Collapse all pages Réduire toutes les pages + Expand all pages Développer toutes les pages Remove page Retirer la page Remove {n} pages Retirer {n} pages diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fe31b0e7a8..b47da5b412 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2255,6 +2255,8 @@ key Space Szóköz Switch to List view to change the pages it takes. Válts listanézetre, ha módosítanád, mely oldalak kerüljenek bele. Expand all Összes kibontása Collapse all Összes összecsukása + Collapse all pages Összes oldal összecsukása + Expand all pages Összes oldal kibontása Remove page Oldal eltávolítása Remove {n} pages {n} oldal eltávolítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0cda1c3232..9d54a4bead 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2267,6 +2267,8 @@ key Space Spazio Switch to List view to change the pages it takes. Passa alla vista elenco per cambiare le pagine incluse. Expand all Espandi tutto Collapse all Comprimi tutto + Collapse all pages Comprimi tutte le pagine + Expand all pages Espandi tutte le pagine Remove page Rimuovi pagina Remove {n} pages Rimuovi {n} pagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 48bcb8f25d..461e074935 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2256,6 +2256,8 @@ key Space スペース Switch to List view to change the pages it takes. 使用するページを変更するには、リスト表示に切り替えてください。 Expand all すべて展開 Collapse all すべて折りたたむ + Collapse all pages すべてのページを折りたたむ + Expand all pages すべてのページを展開 Remove page ページを削除 Remove {n} pages {n} ページを削除 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 13a9f67297..af140d3a07 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2142,6 +2142,8 @@ calendar date December Желтоқсан Check the pages Беттерді белгілеу Closed arrow Жабық көрсеткі Collapse all Барлығын жию + Collapse all pages Барлық беттерді жию + Expand all pages Барлық беттерді жаю Column select Баған бойынша таңдау Comb of characters Таңбалар торы Custom font Арнаулы қаріп diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 165163d31f..247b6cff95 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -377,6 +377,8 @@ Closed arrow Uždara rodyklė Collapse Suskleisti Collapse all Suskleisti viską + Collapse all pages Suskleisti visus puslapius + Expand all pages Išskleisti visus puslapius Collapse all bookmarks Suskleisti visas žymeles Collapse tag Suskleisti žymą Collate Rūšiuoti komplektais diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5cc4093606..3dbb247056 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1310,6 +1310,8 @@ Expand all bookmarks Rozwiń wszystkie zakładki Expand top-level bookmarks Rozwiń zakładki najwyższego poziomu Collapse all bookmarks Zwiń wszystkie zakładki + Collapse all pages Zwiń wszystkie strony + Expand all pages Rozwiń wszystkie strony New bookmark (⌘B) Nowa zakładka (⌘B) This document has no bookmarks. Ten dokument nie zawiera zakładek. This document has no layers. Ten dokument nie zawiera warstw. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8756fa94ee..3e55173169 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2258,6 +2258,8 @@ key Space Espaço Switch to List view to change the pages it takes. Mude para a exibição em lista para alterar as páginas incluídas. Expand all Expandir tudo Collapse all Recolher tudo + Collapse all pages Recolher todas as páginas + Expand all pages Expandir todas as páginas Remove page Remover página Remove {n} pages Remover {n} páginas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d9fe1a98b4..1ac9c10ba1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2173,6 +2173,8 @@ key Space Пробел Switch to List view to change the pages it takes. Переключитесь на вид списком, чтобы изменить, какие страницы взять. Expand all Развернуть все Collapse all Свернуть все + Collapse all pages Свернуть все страницы + Expand all pages Развернуть все страницы Remove page Удалить страницу Remove {n} pages Удалить страницы: {n} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index d59de3df85..4165909995 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2192,6 +2192,8 @@ signature pad Type Vtipkaj Auto follows the system's text size. Pages always render at the display's full resolution. Samodejno sledi velikosti besedila v sistemu. Strani se vedno izrišejo v polni ločljivosti zaslona. Check the pages Preveri strani Collapse all Strni vse + Collapse all pages Strni vse strani + Expand all pages Razširi vse strani Column select Izbira stolpcev Drag the selection to move it. Esc clears the selection. Povlecite izbor, da ga premaknete. Esc počisti izbor. Expand all Razširi vse diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index ea10d79910..07b716eac1 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2181,6 +2181,8 @@ key Space స్పేస్ Reset page size పేజీ పరిమాణాన్ని రీసెట్ చేయి Expand all అన్నీ విస్తరించు Collapse all అన్నీ ముడుచు + Collapse all pages అన్ని పేజీలను ముడుచు + Expand all pages అన్ని పేజీలను విస్తరించు Remove page పేజీని తీసివేయి Remove {n} pages {n} పేజీలను తీసివేయి diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 31fce37c84..9fbd4dc966 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2215,6 +2215,8 @@ key Space Boşluk Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Bazı yer imleri burada gösterilmiyor: panel sınırlı sayıda ve sınırlı derinlikte listeler. Expand all Tümünü genişlet Collapse all Tümünü daralt + Collapse all pages Tüm sayfaları daralt + Expand all pages Tüm sayfaları genişlet Remove page Sayfayı kaldır Remove {n} pages {n} sayfayı kaldır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index db6e7b0ffc..53bc832f55 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2280,6 +2280,8 @@ key Space Пробіл Switch to List view to change the pages it takes. Перейдіть до вигляду списком, щоб змінити, які сторінки взяти. Expand all Розгорнути всі Collapse all Згорнути всі + Collapse all pages Згорнути всі сторінки + Expand all pages Розгорнути всі сторінки Remove page Видалити сторінку Remove {n} pages Видалити сторінки: {n} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d35f951512..a01b47f8a1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -374,6 +374,8 @@ theme Appearance Giao diện Closed arrow Mũi tên đóng Collapse Thu gọn Collapse all Thu gọn tất cả + Collapse all pages Thu gọn tất cả các trang + Expand all pages Mở rộng tất cả các trang Collapse all bookmarks Thu gọn tất cả dấu trang Collapse tag Thu gọn thẻ Collate Sắp theo bộ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 72b0cd30a9..3274ff4731 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2224,6 +2224,8 @@ key Space 空格 Switch to List view to change the pages it takes. 切换到列表视图以更改要使用的页面。 Expand all 全部展开 Collapse all 全部折叠 + Collapse all pages 折叠所有页面 + Expand all pages 展开所有页面 Remove page 删除页面 Remove {n} pages 删除 {n} 页 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6689525548..0ea252e56c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2139,6 +2139,8 @@ key Space 空格 Switch to List view to change the pages it takes. 切換到清單檢視以變更要使用的頁面。 Expand all 全部展開 Collapse all 全部摺疊 + Collapse all pages 摺疊所有頁面 + Expand all pages 展開所有頁面 Remove page 刪除頁面 Remove {n} pages 刪除 {n} 頁 diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 7450052405..914bc281b8 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -735,6 +735,61 @@ fn the_panel_filters_and_sorts() { assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added"); } +/// #1554: a page group's chevron folds that page's cards away and back, and the choice holds. +#[test] +fn page_groups_collapse_and_expand() { + let mut h = harness(|app| app.set_option("panel", "comments").unwrap()); + for (page, text) in [(0, "First page note"), (1, "Second page note")] { + let shape = pdfcraft_engine::Shape::Note { at: [100.0, 100.0], icon: pdfcraft_engine::NoteIcon::Comment }; + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: text.into(), + author: "Tester".into(), + })); + } + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + let shown = |h: &Harness<'static, PdfCraftApp>, text: &str| h.query_all_by_label_contains(text).count() > 0; + assert!(shown(&h, "First page note") && shown(&h, "Second page note")); + assert_eq!(h.query_all_by_label("Collapse").count(), 2, "one chevron per page group"); + + h.query_all_by_label("Collapse").next().unwrap().click(); + h.run_steps(3); + assert!(!shown(&h, "First page note"), "page 1's card is folded away"); + assert!(shown(&h, "Second page note"), "page 2 stays open"); + h.run_steps(5); + assert!(!shown(&h, "First page note"), "the group stays folded on later frames"); + + h.get_by_label("Expand").click(); + h.run_steps(3); + assert!(shown(&h, "First page note"), "page 1's card is back"); + + // Selecting a comment on a folded page opens its group so the card can be shown. + h.query_all_by_label("Collapse").next().unwrap().click(); + h.run_steps(3); + assert!(!shown(&h, "First page note")); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.state_mut().views[0].comments.reveal = true; + h.run_steps(3); + assert!(shown(&h, "First page note"), "the selected comment's group opened"); + + // "…" ▸ Collapse all pages, then Expand all pages. + h.state_mut().views[0].comments.selected = None; + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Collapse all pages").click(); + h.run_steps(3); + assert!(!shown(&h, "First page note") && !shown(&h, "Second page note"), "every page is folded"); + assert_eq!(h.query_all_by_label("Expand").count(), 2); + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Expand all pages").click(); + h.run_steps(3); + assert!(shown(&h, "First page note") && shown(&h, "Second page note"), "every page is open again"); +} + #[test] fn comments_take_checkmarks_lock_hide_and_summarize() { let mut h = harness(|app| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cfe560a940..e841dec082 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3553,8 +3553,8 @@ milestone = "M3" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"] -notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box" +evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::page_groups_collapse_and_expand"] +notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" [[feature]] id = "comment.appearance-generation" From afa8e1843cfed613f3d403119f6c2649bce824c9 Mon Sep 17 00:00:00 2001 From: tomasmark79 <44719504+tomasmark79@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:08:34 +0200 Subject: [PATCH 19/19] M3: Wayland: stop retaining Ctrl zoom for pixel-only wheel input (#1569) * M3: release Ctrl zoom for pixel-only Wayland wheel events Use the reported scroll source to avoid unterminated finger gestures for wheel, wheel-tilt and continuous input. Exercise the production policy through egui, including modifier release and preserved finger momentum. Assisted-by: OpenAI Codex * M3: keep Cargo lockfile dependency ordering canonical Record the dependency ordering produced by the completed workspace and wasm gates. Assisted-by: OpenAI Codex --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 1 + apps/pdfcraft/Cargo.toml | 4 + apps/pdfcraft/tests/winit_wayland_scroll.rs | 76 +++++++++++++++++++ vendor/README.md | 2 +- .../linux/wayland/seat/pointer/mod.rs | 25 +++--- .../linux/wayland/seat/pointer/scroll.rs | 21 +++++ 7 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 apps/pdfcraft/tests/winit_wayland_scroll.rs create mode 100644 vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs diff --git a/Cargo.lock b/Cargo.lock index 5dcaf7b1b5..91ed3c4904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3951,6 +3951,7 @@ dependencies = [ "wayland-client", "wayland-protocols", "windows", + "winit", "winresource", "winsafe", ] diff --git a/ROADMAP.md b/ROADMAP.md index e71d88dc76..fe9723fdf6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index a037992975..a27401c700 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -48,6 +48,10 @@ raw-window-handle = "0.6" # Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu). pollster = "1.0" +[target.'cfg(target_os = "linux")'.dev-dependencies] +# Exercise the vendored Wayland scroll policy with the actual winit event type. +winit = { version = "0.30", default-features = false, features = ["wayland"] } + [target.'cfg(target_os = "windows")'.dependencies] # Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that # shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it). diff --git a/apps/pdfcraft/tests/winit_wayland_scroll.rs b/apps/pdfcraft/tests/winit_wayland_scroll.rs new file mode 100644 index 0000000000..ec76d2b4b9 --- /dev/null +++ b/apps/pdfcraft/tests/winit_wayland_scroll.rs @@ -0,0 +1,76 @@ +#![cfg(target_os = "linux")] + +use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; +use wayland_client::protocol::wl_pointer::AxisSource; + +// Compile the production policy against winit's real event type, without a compositor. +mod event { + pub use winit::event::TouchPhase; +} +#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] +mod scroll; + +fn egui_phase(phase: event::TouchPhase) -> TouchPhase { + match phase { + event::TouchPhase::Started => TouchPhase::Start, + event::TouchPhase::Moved => TouchPhase::Move, + event::TouchPhase::Ended => TouchPhase::End, + event::TouchPhase::Cancelled => TouchPhase::Cancel, + } +} + +fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { + let ctx = egui::Context::default(); + let mut previous = event::TouchPhase::Ended; + let mut seen = Vec::new(); + let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (frame, modifiers) in [ctrl, ctrl, Modifiers::NONE].into_iter().enumerate() { + let phase = scroll::phase(previous, Some(source), discrete, false); + previous = phase; + let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) }; + let input = RawInput { + time: Some(frame as f64 / 60.0), + events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }], + ..Default::default() + }; + ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear(); + } + seen +} + +#[test] +fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { + for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { + let seen = wheel_trace(source, false); + assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}"); + assert_eq!(seen[2].0, 1.0, "releasing Ctrl must stop zooming: {source:?}"); + assert!(seen[2].1.y > 0.0, "the next wheel event must scroll: {source:?}"); + } +} + +#[test] +fn discrete_wheels_still_follow_current_modifiers() { + let seen = wheel_trace(AxisSource::Wheel, true); + assert!(seen[1].0 > 1.0); + assert_eq!(seen[2].0, 1.0); + assert!(seen[2].1.y > 0.0); +} + +#[test] +fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { + // A finger scroll's momentum may arrive after Ctrl is released. + let seen = wheel_trace(AxisSource::Finger, false); + assert!(seen[1].0 > 1.0); + assert!(seen[2].0 > 1.0); + assert_eq!(seen[2].1, egui::Vec2::ZERO); + assert_eq!(scroll::phase(event::TouchPhase::Moved, Some(AxisSource::Finger), false, true), event::TouchPhase::Ended); + assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); +} + +#[test] +fn unknown_sources_keep_the_existing_phase_policy() { + assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); + assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved); + assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved); + assert_eq!(scroll::phase(event::TouchPhase::Moved, None, false, true), event::TouchPhase::Ended); +} diff --git a/vendor/README.md b/vendor/README.md index 4af80f7059..fb6588c142 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -24,7 +24,7 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references` | -| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer | +| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs index 3dcb00b269..8a789a8696 100644 --- a/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs +++ b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs @@ -34,6 +34,7 @@ use crate::platform_impl::wayland::state::WinitState; use crate::platform_impl::wayland::{self, DeviceId, WindowId}; pub mod relative_pointer; +mod scroll; impl PointerHandler for WinitState { fn pointer_frame( @@ -169,26 +170,20 @@ impl PointerHandler for WinitState { window_id, ); }, - PointerEventKind::Axis { horizontal, vertical, .. } => { + PointerEventKind::Axis { horizontal, vertical, source, .. } => { // Get the current phase. let mut pointer_data = pointer.winit_data().inner.lock().unwrap(); let has_discrete_scroll = horizontal.discrete != 0 || vertical.discrete != 0; - // Figure out what to do about start/ended phases here. - // - // Figure out how to deal with `Started`. Also the `Ended` is not guaranteed - // to be sent for mouse wheels. - let phase = if horizontal.stop || vertical.stop { - TouchPhase::Ended - } else { - match pointer_data.phase { - // Discrete scroll only results in moved events. - _ if has_discrete_scroll => TouchPhase::Moved, - TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved, - _ => TouchPhase::Started, - } - }; + // PdfCraft patch (13, #991): use the source as well as discrete steps; + // pixel-only wheels do not promise an Ended event either. + let phase = scroll::phase( + pointer_data.phase, + source, + has_discrete_scroll, + horizontal.stop || vertical.stop, + ); // Update the phase. pointer_data.phase = phase; diff --git a/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs new file mode 100644 index 0000000000..7916371320 --- /dev/null +++ b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs @@ -0,0 +1,21 @@ +//! PdfCraft patch (13): Wayland scroll-phase policy, shared with the desktop regression tests. + +use wayland_client::protocol::wl_pointer::AxisSource; + +use crate::event::TouchPhase; + +pub(crate) fn phase(previous: TouchPhase, source: Option, has_discrete_scroll: bool, stopped: bool) -> TouchPhase { + // Wayland does not guarantee axis_stop for these sources. A pixel-only wheel must + // not start a finger gesture: consumers would retain its modifiers indefinitely. + // https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_pointer-event-axis_source + if stopped { + TouchPhase::Ended + } else if has_discrete_scroll || matches!(source, Some(AxisSource::Wheel | AxisSource::WheelTilt | AxisSource::Continuous)) { + TouchPhase::Moved + } else { + match previous { + TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved, + _ => TouchPhase::Started, + } + } +}