From 7e3f413f528a867411fa1fae5911a94a548cdcff Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:48:02 +0200 Subject: [PATCH 01/12] Redact email addresses whose local part starts with punctuation (#2948) Found while testing crates/redact/src/patterns.rs:141-146 (email) Co-authored-by: Claude Haiku 5.5 --- crates/redact/src/patterns.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 18fc65e33..f66d159e7 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -140,14 +140,16 @@ fn address_char(c: char) -> bool { fn email(s: &[char], i: usize) -> Option { let local = |c: &char| address_char(*c) || "._%+-".contains(*c); - if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { + // A match starts at the first character of a run of local characters, which may be + // punctuation ("_admin@host.com"), but the run needs a letter or digit. + if !local(&s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { return None; } let mut j = i; while j < s.len() && local(&s[j]) { j += 1; } - if s.get(j) != Some(&'@') { + if s.get(j) != Some(&'@') || !s[i..j].iter().any(|c| address_char(*c)) { return None; } let d0 = j + 1; @@ -401,6 +403,18 @@ mod tests { ); } + #[test] + fn emails_with_a_leading_punctuation_character() { + for lead in ["_", "-", ".", "%", "+"] { + let address = format!("{lead}john@example.com"); + assert_eq!(found(Pattern::Email, &format!("Write to {address} now")), [address.as_str()], "lead {lead:?}"); + } + // A run of local characters is still matched whole, never from its middle. + assert_eq!(found(Pattern::Email, "a.b@x.com, foo_john@example.com"), ["a.b@x.com", "foo_john@example.com"]); + // Punctuation alone is no local part. + assert_eq!(found(Pattern::Email, "Write to ..@example.com or -@example.com"), Vec::::new()); + } + #[test] fn internationalized_emails() { assert_eq!( From f0c275669ad80a4d643965b97ae8b859c333c00d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:48:14 +0800 Subject: [PATCH 02/12] M7: never encode typed text with CID 0 (#3223) (#3235) Word's Type 0 subsets map CID 0 (.notdef) as well as the real space glyph to U+0020 in ToUnicode and list a width for CID 0. encode keeps the lowest code for each character, so an edited line wrote every space as <0000> and drew a box where each space was. CID 0 is .notdef whatever ToUnicode says, so has_glyph now refuses it and the space takes the real CID. Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/pdf.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index bb22d1545..26685866b 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -839,6 +839,11 @@ impl Metrics { fn has_glyph(&self, code: u32) -> bool { if self.composite { let cid = self.cid(code); + // CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word + // maps it to a space and gives it a width, and typing with it draws boxes (#3223). + if cid == 0 { + return false; + } // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) @@ -1456,6 +1461,34 @@ mod tests { assert_eq!(m.encode("Z"), None); } + /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a + /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). + #[test] + fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); + } + /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is /// still in the font, but nothing is when the font gives no default width to point at. #[test] @@ -1754,7 +1787,8 @@ mod tests { assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); } let mut reverse: HashMap = HashMap::new(); - for (&code, text) in &expected { + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { if let Some(c) = only_char(text) { keep(&mut reverse, c, code); } From 2bd6d1357da4129be429127c000966eaa8ffa5e2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:48:26 +0800 Subject: [PATCH 03/12] M5: flattening comments leaves 3D, multimedia and print-production annotations (#3087) (#3089) doc_flatten with comments chose every non-widget annotation except Link and Popup, so a comments-only flatten baked in and deleted 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet and Watermark annotations with their data (a 3D annotation's U3D model and default view). The comments branch now uses pdfcraft_annot::is_comment_subtype, the classifier the comment list uses; widgets follow `fields` only. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/edit/src/flatten.rs | 10 +++++++-- crates/edit/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d8..3ac546805 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -63,9 +63,15 @@ enum Which { fn selected(doc: &Document, d: &Dict, which: Which) -> bool { let subtype = d.name(b"Subtype").unwrap_or_default(); match which { + // Comments are what the comment list calls comments: links, pop-ups and the non-markup + // multimedia and print-production annotations (3D, Screen, Movie, RichMedia, + // PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087). Which::Comments { comments, fields } => { - let widget = subtype == b"Widget"; - !matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments) + if subtype == b"Widget" { + fields + } else { + comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype)) + } } Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d), } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7930a7ead..299356b29 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4498,3 +4498,43 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1); assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } + +/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup +/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, +/// TrapNet, Watermark), which it used to bake into the page and delete with their data. +/// Flattening fields only leaves them alone too; real comments are still flattened. +#[test] +fn flattening_comments_keeps_annotations_that_are_not_comments() { + let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"]; + let flattened = ["Text", "Square", "FileAttachment"]; + let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect(); + let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream"; + let annots: Vec = all + .iter() + .enumerate() + .map(|(i, s)| { + format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i) + }) + .collect(); + let refs: Vec = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" ")); + let content = stream("", " "); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content]; + objs.extend(annots.iter().map(String::as_str)); + let left = |doc: &Document| -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() }; + a.as_array() + .unwrap() + .iter() + .filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned())) + .collect() + }; + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len()); + assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened"); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget"); + let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect(); + assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f05387aa2..245d439af 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4208,8 +4208,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] +notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" From 25495b959b1fe4f9480e19cb14dc9869da3e0f74 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:48:49 +0200 Subject: [PATCH 04/12] Keep a subscript or superscript in its line when extracting text (#3174) (#3244) * Keep a subscript or superscript in its line when extracting text (#3174) * Text extraction: only a glyph right beside the previous one gets the sub/superscript centre limit A large negative gap (a small glyph drawn back under a large one) kept the wider limit. --------- Co-authored-by: Brandon Thomas --- crates/render/src/text.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 505485996..6784f14eb 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1401,6 +1401,7 @@ fn union(a: &mut [f32; 4], b: &[f32; 4]) { /// /// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8): /// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap. +/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment. /// Segments on the same baseline that nearly touch are merged (text drawn out of order). /// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally. /// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left @@ -1453,7 +1454,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let Some(p) = s.first.end.checked_sub(1) else { return false }; let ph = height(p).min(height(i)); let gap = g[0] - glyphs[p].rect[2]; - (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 + // A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by + // more than half its own height, but by less than half the height of the full-size glyph beside it. + let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 }; + (cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 }); let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress)); if cont @@ -1636,6 +1640,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { mod tests { use super::*; + fn em_box(text: &str, x0: f32, y0: f32, x1: f32, y1: f32) -> TextGlyph { + TextGlyph { text: text.into(), rect: [x0, y0, x1, y1], quad: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], direction: [1.0, 0.0] } + } + + #[test] + fn subscript_stays_in_its_word() { + // "CO2-E": an 8.1 pt "CO" and "-E", with a 4.7 pt "2" drawn 2.7 pt lower (#3174). + let glyphs = vec![ + em_box("C", 0.0, 92.5, 5.0, 100.6), + em_box("O", 5.0, 92.5, 11.0, 100.6), + em_box("2", 11.0, 97.9, 13.5, 102.6), + em_box("-", 13.5, 92.5, 16.0, 100.6), + em_box("E", 16.0, 92.5, 21.0, 100.6), + ]; + assert_eq!(layout(glyphs).plain_text(), "CO2-E"); + } + #[test] fn identity_v_columns_are_searchable_in_reading_order() { use lopdf::{Document, Object, Stream, dictionary}; From 2eb16390976b7864a7a57731fec35a4d1a56dbee Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:49:13 +0200 Subject: [PATCH 05/12] Find credit card numbers followed by an expiry, year or CVV (#2969) * Find credit card numbers followed by an expiry, year or CVV Found while testing crates/redact/src/patterns.rs:180-190 (credit_card, via digit_run at :53) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * Redact: a card followed by a number drops at most four trailing digits A longer grouped number (an IBAN's digits) no longer matches on a Luhn-valid prefix; the run is read far enough to see the tail and refused when it goes on past that. --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/redact/src/patterns.rs | 41 +++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index f66d159e7..4da8236c2 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -183,12 +183,33 @@ fn credit_card(s: &[char], i: usize) -> Option { if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) { return None; } - let (end, n) = digit_run(s, i, &[' ', '-'], 23); - if !(13..=19).contains(&n) || digit(s.get(end)) { + // Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits. + let (end, n) = digit_run(s, i, &[' ', '-'], 29); + // A run longer than that is some other, longer number. + if end == i || run_continues(s, end - 1, 1, &[' ', '-']) { return None; } - let digits: Vec = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect(); - luhn(&digits).then_some(end - i) + // The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then + // each shorter run ending at a group boundary, longest first. The dropped tail is at most four + // digits, so a longer number (an IBAN's digits) doesn't match on a prefix. + let mut count = n; + for e in (i + 1..=end).rev() { + if n - count > 4 { + break; + } + if !s.get(e - 1).is_some_and(char::is_ascii_digit) { + continue; + } + let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) }; + if boundary && (13..=19).contains(&count) { + let digits: Vec = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect(); + if luhn(&digits) { + return Some(e - i); + } + } + count = count.saturating_sub(1); + } + None } fn ssn(s: &[char], i: usize) -> Option { @@ -435,6 +456,18 @@ mod tests { ); } + #[test] + fn credit_card_followed_by_another_number() { + for tail in ["12/26", "2026", "123", "12 26"] { + assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}"); + } + assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]); + // A run that is not a card at any group boundary still matches nothing. + assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::::new()); + // A longer grouped number doesn't match on a card-shaped prefix. + assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::::new()); + } + #[test] fn social_security_numbers() { assert_eq!( From a50ddd4de112b4b85f79ed4618e7395e49bc6e1c Mon Sep 17 00:00:00 2001 From: hms5232 <43672033+hms5232@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:49:36 +0800 Subject: [PATCH 06/12] fix(i18n): zh-hant catalog uses Taiwan vocabulary consistently (#2856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several entries carried mainland-Chinese computing terms in a catalog written in the vocabulary used in Taiwan: - 丟失 → 遺失 (unsaved-changes warning) - 郵政編碼 → 郵遞區號 (Zip Code field formats) - 自簽名 → 自我簽署 (self-signed digital IDs, three entries) - 灰度 → 灰階 (Grayscale Images, Print in grayscale; the rest of the file already used 灰階) - 匹配 → 相符 (ten search/match entries; 符合 stays for the fit/conform sense: 符合頁面, 符合 PDF/A) Translation column only — source keys, placeholders and ellipses unchanged. ATTRIBUTION.toml hash re-pinned. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 36 ++++++++++++++--------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 87b11279e..80a5e0f54 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "349a2ead371bdbfeefb9ab2279c676b205cefe163cbef174f8123fe4bf739ab9" +sha256 = "7b98d720bc0c433d62b7b4fb4a5b79d3e74111590c29a59a51f3944e5e0622a5" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0cd22d7ff..ded4ad904 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -109,7 +109,7 @@ Sanitize 清理 Mark all 全部標記 Save changes to “{name}” before closing? 在關閉前儲存對「{name}」的更改? - Your changes will be lost if you don't save them. 如果不儲存,更改將會丟失。 + Your changes will be lost if you don't save them. 如果不儲存,更改將會遺失。 Password required 需要密碼 “{name}” is protected. Enter a password to open it. 「{name}」受保護。請輸入密碼以開啟。 Password 密碼 @@ -190,7 +190,7 @@ Discard User Data 捨棄使用者資料 Clean Up 清理 Color Images 彩色圖片 - Grayscale Images 灰度圖片 + Grayscale Images 灰階圖片 Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 @@ -303,8 +303,8 @@ Negative number style: 負數樣式: Format: 格式: Custom: 自訂: - Zip Code 郵政編碼 - Zip Code + 4 郵政編碼 + 4 + Zip Code 郵遞區號 + Zip Code + 4 郵遞區號 + 4 Phone Number 電話號碼 Social Security Number 社會安全號碼 Arbitrary Mask 任意掩碼 @@ -503,7 +503,7 @@ Continue 繼續 Configure a Digital ID for Signing 設定簽署用的數位 ID Use a Digital ID from a file 使用檔案中的數位 ID - Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自簽名,儲存到受密碼保護的檔案) + Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自我簽署,儲存到受密碼保護的檔案) Name 名稱 Organizational Unit 組織單位 Organization Name 組織名稱 @@ -511,7 +511,7 @@ Country/Region 國家/地區 Key Algorithm 金鑰演算法 Confirm Password 確認密碼 - Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自簽名 ID 通常不受他人信任。 + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自我簽署 ID 通常不受他人信任。 File (.p12, .pfx) 檔案 (.p12, .pfx) Digital ID 數位 ID Browse… 瀏覽… @@ -566,7 +566,7 @@ Public key 公鑰 Key usage 金鑰用途 Not present 不存在 - Self-signed 自簽名 + Self-signed 自我簽署 Edit Action 編輯操作 New Action 新建操作 Action name: 操作名稱: @@ -772,9 +772,9 @@ Social Security Numbers 社會安全號碼 Dates 日期 Text in images isn't found; recognise text (OCR) first. 圖片中的文字無法找到;請先識別文字(OCR)。 - No matches. 無匹配。 + No matches. 無相符。 Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 此處未顯示部分書籤:面板列出的書籤數量和巢狀深度有限。 - {n} match(es) marked for redaction. 已標記 {n} 個匹配以待遮蔽內容。 + {n} match(es) marked for redaction. 已標記 {n} 個相符以待遮蔽內容。 Multiple words or phrases 多個單字或片語 One word or phrase per line 每行一個單字或片語 Import list… 匯入清單… @@ -815,7 +815,7 @@ {name} (default) {name}(預設) Copies: 份數: Collate 逐份列印 - Print in grayscale 灰度列印 + Print in grayscale 灰階列印 Two-sided: 雙面: Off 關 Flip on long edge 沿長邊翻轉 @@ -1174,7 +1174,7 @@ Add a comment 新增註解 No comments yet. 尚無註解。 Pick a tool in the quick bar, or type above. 在快捷欄中選擇工具,或在上方輸入。 - No comments match. 沒有匹配的註解。 + No comments match. 沒有相符的註解。 Unknown author 未知作者 No colour 無顏色 Remove checkmark 刪除勾選標記 @@ -1231,13 +1231,13 @@ Open in a new tab 在新索引標籤中開啟 This document has no pages. 本文件沒有頁面。 Find text 尋找文字 - No matches 無匹配 + No matches 無相符 Searching… {s}/{p} 正在搜尋… {s}/{p} {c} of {n}{more} {c} / {n}{more} Previous ({key}) 上一個 ({key}) Next ({key}) 下一個 ({key}) Find options 尋找選項 - Whole words only 僅全字匹配 + Whole words only 僅全字相符 Case-sensitive 區分大小寫 Close (Esc) 關閉 (Esc) Align 對齊 @@ -1302,7 +1302,7 @@ · password-protected · 受密碼保護 Recover 恢復 Discard 放棄 - Next / previous match 下一個/上一個符合項目 + Next / previous match 下一個/上一個相符項目 Copy selected text 複製所選文字 Select a word 選取一個詞 Clear selection / close find 清除選取/關閉尋找 @@ -1618,7 +1618,7 @@ the name is not a plain file name 名稱不是單純的檔案名稱 Wrote 1 file to {dir} 已將 1 個檔案寫入{dir} Wrote {n} files to {dir} 已將{n}個檔案寫入{dir} - No pages match those choices 所選條件沒有匹配的頁面 + No pages match those choices 所選條件沒有相符的頁面 Couldn't split: {e} 無法拆分:{e} Couldn't summarize comments: {e} 無法彙總註解:{e} Couldn't open the result: {e} 無法開啟結果:{e} @@ -1693,8 +1693,8 @@ What word or phrase would you like to search for? 您想搜尋哪個詞或短語? Searching… {s} of {p} pages 正在搜尋… {s}/{p} 頁 (searching… {s} of {p} pages) (正在搜尋… {s}/{p} 頁) - 1 instance 1 個匹配 - {n} instances {n} 個匹配 + 1 instance 1 個相符 + {n} instances {n} 個相符 Go to this result 轉到此結果 Highlight 突出顯示 Draw 繪製 @@ -1743,7 +1743,7 @@ Tool 工具 Command 指令 Search tools and commands… 搜尋工具與指令… - No matching tools 找不到符合的工具 + No matching tools 找不到相符的工具 All tools 所有工具 Read 閱讀 Convert 轉換 From 1581cd1edd883708ece0d3fc381b84296be5a938 Mon Sep 17 00:00:00 2001 From: Ian Holloway <72767437+IanHollow@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:50:16 -0700 Subject: [PATCH 07/12] M10: keep the OCR scan fixture at its original page size (#2399) Assisted-by: GPT-6 Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> Co-authored-by: Brandon Thomas --- crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 55e8eee0f..1c473b780 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3339,6 +3339,35 @@ fn recognize_text_reads_the_euro_sign() { assert!(text.contains("€250"), "{text}"); } +const SYNTHETIC_SCAN_DPI: f64 = 300.0; + +/// A scan of the OCR sentence at the source page's physical size, without a text layer. +fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) { + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let original = &s.get(id).unwrap().info.pages[0]; + let size = [original.width, original.height]; + // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. + let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap(); + // Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge + // the page and change the PDF-point coordinates the placement assertions exercise. + let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap(); + (s.open("scan.pdf", None, scan, None).unwrap(), size) +} + +#[test] +fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let (id, expected) = synthetic_ocr_scan(&mut s); + let page = &s.get(id).unwrap().info.pages[0]; + // Rounding to whole raster pixels may change a page edge by at most one pixel. + let tolerance = 72.0 / SYNTHETIC_SCAN_DPI; + for (actual, expected) in [page.width, page.height].into_iter().zip(expected) { + assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt"); + } + assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs"); +} + #[test] fn recognize_text_makes_a_scanned_page_searchable() { if !ocr::available() { @@ -3346,12 +3375,7 @@ fn recognize_text_makes_a_scanned_page_searchable() { return; } let mut s = Session::new().with_clock(|| 1_700_000_000); - let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); - let id = s.open("text.pdf", None, text, None).unwrap(); - // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap(); - let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); - let id = s.open("scan.pdf", None, scan, None).unwrap(); + let (id, _) = synthetic_ocr_scan(&mut s); assert_eq!(page_texts(&s, id), [""], "a picture has no text"); let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); From 6ce3bc198bda36ef8246d875b97b72c495473c81 Mon Sep 17 00:00:00 2001 From: Andrew Barnes <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:50:42 -0400 Subject: [PATCH 08/12] fix(gpu): request adapter-supported device limits (#2383) (#2433) Clamp the surface device descriptor to the adapter's capabilities while preserving its maximum 2D texture extent. Cover V3D shader variables, lower GL capabilities, alignment ordering and capable-device defaults. Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++++ apps/pdfcraft/src/main.rs | 42 ++++++++++++++++++++++++++++++++++++--- docs/roadmap.md | 5 ++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40a9d8c56..fda6dca10 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,7 @@ > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -170,6 +171,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **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-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are 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. @@ -506,6 +508,8 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 4d9857ce1..b274dc53a 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -565,7 +565,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); // egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces. // A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in - // Surface::configure before the app starts. Keep the renderer's other device requirements, + // Surface::configure before the app starts. Keep the renderer's other compatible requirements, // but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters). let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor); setup.device_descriptor = std::sync::Arc::new(move |adapter| { @@ -606,8 +606,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { } /// Surface textures must fit the device's enabled limits, not just the physical GPU's limits. -/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements. +/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent. fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits { + required = required.or_worse_values_from(supported); required.max_texture_dimension_2d = supported.max_texture_dimension_2d; required } @@ -912,16 +913,51 @@ mod tests { fn surface_limits_preserve_other_requirements_and_never_overrequest() { for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() }; + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; let enabled = super::surface_texture_limits(required.clone(), &supported); let mut expected = required.clone(); expected.max_texture_dimension_2d = extent; assert_eq!(enabled, expected); assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); } } } + #[test] + fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); + } + + #[test] + fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); + } + #[test] fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { use eframe::egui_wgpu::winit::surface_fit; diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e772e32e..f27029f5a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) @@ -138,7 +139,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. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. 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 | @@ -183,6 +184,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | From 2c4989413fc100b4110f828e1b27d9ec0c2869de Mon Sep 17 00:00:00 2001 From: Yoann LAISSUS Date: Sun, 18 Oct 2026 15:51:52 +0200 Subject: [PATCH 09/12] M7: the Edit text caret is as tall as the row it is on (#2462) In a region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below. egui draws the caret at least one row of the TextEdit's font tall, and that font had the paragraph's first size. Each row already carries its source row's height, so the TextEdit's font is now half the region's smallest size and only sets that floor. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 ++ crates/ui-egui/src/edit_text_ui.rs | 7 +++- crates/ui-egui/tests/editing.rs | 52 ++++++++++++++++++++++++++++++ docs/roadmap.md | 2 ++ parity/acrobat-features.toml | 2 +- 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fda6dca10..13d57e14a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) @@ -163,6 +164,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-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. @@ -512,6 +514,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index cbec130b8..66ad98ffe 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1329,7 +1329,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - ed.max_width = ed.rect.width(); let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01); ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0); - let font = editor_font(&ed.look, ed.size); + // egui draws the caret at least one row of this font tall. Each row already has its source + // row's height, so the font stays below every row (half the region's smallest size): on a + // 12 pt row under a 24 pt title the caret is as tall as the row, not the title. + let smallest = + ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min)); + let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size))); let text_color = color32(ed.look.color); let mut done = None; egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index b30d8a70e..0c7f7196c 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3832,6 +3832,58 @@ fn one_editor_contains_mixed_styles_and_visual_rows() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rows.len(), 3); } +#[test] +fn the_caret_on_a_small_row_is_as_tall_as_that_row() { + // It was one row of the region's first (18 pt) size tall, reaching into the row below. + let bytes = source_font_fixture_with_text( + "BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \ + BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \ + BT /F2 10 Tf 20 195 Td (Last 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.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 k = page.width() / 300.0; + let screen = |x: f32, y: f32| egui::pos2(page.left() + x * k, page.top() + (300.0 - y) * k); + let click = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + }; + click(&mut h, screen(30.0, 245.0)); + assert!(h.state().views[0].line_editor.is_some(), "the region opens"); + let row = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text == "Last row").unwrap().rect; + click(&mut h, screen(40.0, ((row[1] + row[3]) / 2.0) as f32)); + let row_height = (row[3] - row[1]) as f32 * k; + let (left, right) = (screen(row[0] as f32, 0.0).x - 2.0, screen(row[2] as f32, 0.0).x + 2.0); + let middle = screen(0.0, ((row[1] + row[3]) / 2.0) as f32).y; + // The caret: a vertical segment within the row's span, through its middle. + let caret = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], .. } + if (a.x - b.x).abs() < 0.01 && (left..right).contains(&a.x) && a.y.min(b.y) < middle && a.y.max(b.y) > middle => + { + Some((b.y - a.y).abs()) + } + _ => None, + }) + .expect("a caret on the last row"); + assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index f27029f5a..798033793 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) @@ -188,6 +189,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 245d439af..28f608e1a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,7 +1866,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row"] notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] From b68b3c111967049afc357652bb34fbcae91841ad Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:52:40 +0200 Subject: [PATCH 10/12] M3: the Pages panel follows the current page (#2785) Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 4 +++ crates/ui-egui/src/panels.rs | 16 ++++++++- crates/ui-egui/tests/viewer.rs | 64 ++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 13d57e14a..d81487afb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -165,6 +165,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. +- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. - **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cd9a4163a..6148c2748 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -554,6 +554,9 @@ pub struct DocView { pub pages_panel: Option, /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. pub panel_gap: Option, + /// The current page the Pages panel last followed: it scrolls to the current page's + /// thumbnail only when the page changes. + pub(crate) panel_followed: Option, /// How large the page grid draws its pages (1.0 = the usual thumbnails). grid_zoom: f32, /// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid @@ -770,6 +773,7 @@ impl DocView { grid_gap: None, pages_panel: None, panel_gap: None, + panel_followed: None, grid_zoom: 1.0, grid_zoom_request: None, grid_anchor: None, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 69600dac0..0f2c60149 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1064,6 +1064,11 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie { delete_pages(view, info.pages.len()); } + // A new current page scrolls its thumbnail into view, as little as it takes (Acrobat), but + // not while thumbnails are dragged: then the change counts as seen. A wheel turned or a + // scroll bar dragged by the user already wins over a scroll to a place in egui. + let follow = view.panel_followed != Some(view.current) && view.panel_drag.is_none(); + view.panel_followed = Some(view.current); let w = (ui.available_width() - 40.0).min(150.0); let count = info.pages.len(); let mut rows: Vec = Vec::with_capacity(info.pages.len()); @@ -1108,7 +1113,14 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { t.accent_text } else { t.text_muted })); + let label = ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { + t.accent_text + } else { + t.text_muted + })); + if follow && selected { + ui.scroll_to_rect(rect.union(label.rect), None); + } let resp = theme::hand(resp); if resp.clicked() { let m = ui.input(|i| i.modifiers); @@ -1116,6 +1128,8 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.click_page(i, m, true); } else { view.clear_page_selection(Some(i)); + // A page clicked here is in view already: the panel stays put. + view.panel_followed = Some(i); *nav = Some(Nav::Page(i)); } } diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 474360865..31530bf23 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1304,6 +1304,70 @@ fn pages_panel_thumbnails_have_a_context_menu() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![2])); } +/// Where the Pages panel draws page `n`'s thumbnail. +fn thumbnail_rect(h: &Harness<'static, PdfCraftApp>, n: usize) -> egui::Rect { + h.get_all_by_label(&format!("Page {n}")).find(|t| t.rect().left() > 1000.0).expect("the Pages panel thumbnail").rect() +} + +/// Whether `r` lies wholly in the part of the Pages panel in view. +fn in_panel_view(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> bool { + h.state().views[0].pages_panel.expect("the Pages panel is shown").contains_rect(r) +} + +/// #2781: as in Acrobat, going to a page whose thumbnail is out of sight scrolls the Pages +/// panel to show it. +#[test] +fn going_to_a_far_page_scrolls_its_thumbnail_into_view() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + assert!(!in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25 starts out of sight"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert!(in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25's thumbnail is shown"); +} + +/// #2781: a page whose thumbnail is already in view leaves the panel where it is, rather than +/// centring the thumbnail on every page change. +#[test] +fn going_to_a_page_in_view_leaves_the_pages_panel_still() { + // Landscape pages, so several thumbnails share the panel. + let mut h = pages_panel_harness(&[(300, 200); 30]); + h.state_mut().views[0].go_to_page(14); + h.run_steps(4); + let clip = h.state().views[0].pages_panel.expect("the Pages panel is shown"); + // The topmost thumbnail wholly in view, other than the current page's. + let (n, before) = + (1..=30).map(|n| (n, thumbnail_rect(&h, n))).find(|(n, r)| *n != 15 && in_panel_view(&h, *r)).expect("another thumbnail in view"); + assert!( + (before.center().y - clip.center().y).abs() > 50.0, + "page {n} at {before:?} is away from the middle of {clip:?}, where centring would move it" + ); + h.state_mut().views[0].go_to_page(n - 1); + h.run_steps(4); + assert_eq!(h.state().views[0].current, n - 1); + assert_eq!(thumbnail_rect(&h, n), before, "page {n}'s thumbnail stayed put"); +} + +/// #2781: while the user drags thumbnails, a page change does not pull the panel away from +/// where they are being dropped. +#[test] +fn a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + let first = thumbnail_rect(&h, 1); + let at = first.center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.hover_at(at + egui::vec2(0.0, 30.0)); + h.run_steps(2); + assert_eq!(h.state().views[0].panel_drag, Some(vec![0]), "page 1 is being dragged"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert_eq!(thumbnail_rect(&h, 1), first, "the panel stays put under the drag"); +} + /// #444, #186: a mouse-wheel notch scrolls as far as the scroll speed preference says; /// trackpad (point) scrolling keeps its own speed. The choice is saved. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 28f608e1a..47ea3f4e1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1262,8 +1262,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] +notes = "The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] id = "view.thumbnail-context-ops" From 652514095535d91f59ff4f8e4cbce5f6c5f2c2d7 Mon Sep 17 00:00:00 2001 From: Benjamin Michel Date: Sun, 18 Oct 2026 15:53:24 +0200 Subject: [PATCH 11/12] M3: type an exact zoom into the rail's zoom field (#1755) * M3: type an exact zoom into the rail's zoom field The rail's zoom percentage was a read-only label. It is now a text field: Enter applies a typed percentage (150, 150%, 62.5 %), Escape or unparsable input keeps the current zoom. The control channel's zoom option uses the same parser. Co-Authored-By: Claude Opus 5.5 * M3: document parse_zoom_percent * i18n: Vietnamese for the zoom field's tooltip --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 44 ++++++++++++++--------------- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 17 +++++++++++ crates/ui-egui/src/chrome.rs | 34 +++++++++++++++++++--- 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/lib.rs | 2 +- crates/ui-egui/tests/view.rs | 29 +++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 29 files changed, 124 insertions(+), 29 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 80a5e0f54..db3dd6124 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 = "a3234684d5318ce38213c79533d3eebb9c635497ba9e00fb9ca7ebe2fad9d73d" +sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" # 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 = "c5e0206846b009bcb366d092c3d64fd8b9e42d29d5406fb73ae36ecb1f2083d5" +sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" [[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 = "7b98d720bc0c433d62b7b4fb4a5b79d3e74111590c29a59a51f3944e5e0622a5" +sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" [[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 = "cb332907792d4aa760ca9862160e24a7b469afdbcfb93235e99deb8d58604ae2" +sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" [[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 = "ebd3a12e471ba9d9a69e6532fbac2d027dec3c91b79cdfbb1c6c386d13de3bd6" +sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" [[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 = "f5e0553165ab0223bfbc809b1630170d1af11d6546ae5a682d4090104b6e4a05" +sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" [[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 = "dd2171a0a940a84e512fd6408a11cddf104be897ec4ab670c5ef6f1781ccb671" +sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" [[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 = "1b9b3d0d6dab626c3acb8c9aa64d64e56b98eb3f2faf71dcb1957067e72706db" +sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" [[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 = "668f23f4095120343e3f0910dc8f5844765370fae40400c48d685cad20dcf60e" +sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" [[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 = "02f3fd4b2721b0ea9c01c99d477a0339a28180514568179d3fc420e7fb81c671" +sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" [[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 = "26eb438c309d34e55eba959a21f139db91fa5fd1d6d4b7c3d1cd0fa79a60a5b0" +sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" [[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 = "c04b6d0320bce999b33bd189bffcec8a196d4169e56cc65675dc920c5a671dac" +sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" [[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 = "37b042977d934b0b550e5f52faa5d90abbcb9e7ff61ff9588021610ce709e474" +sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" [[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 = "3d1f1d2558eec1271fa653bc38616feeefc6559deeb2a0cdbf07d67634e9ac97" +sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" [[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 = "03071249bd6c13f79da72262415e484fb1aa36098a37a49094b9dab58cebc4e2" +sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" [[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 = "c0ba73b27b95b275f5eebc040ab1c3c6c3b227c63e1c637b4cdb27d0a9264a8b" +sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" [[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 = "e3af76714ee9899328a453ec83195f4dc462fe48ba445fbe3688c777e74bc9dc" +sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" [[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 = "0855d7f785c433799da83400ebc932f987eb543008486bc4f553aadb4102c2ba" +sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" [[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 = "884ab18d60db52f65cd0e30530e196c73c0e627f450bc948517e2f4b7deeb04b" +sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" [[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 = "c805a2c6da08e77a9a7455856d82c168cf6f353d96e1dc140fcc8898f12ba538" +sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" [[asset]] @@ -3352,7 +3352,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 = "11619064ae9edaeee5d601b7008e40095a40375a3e1a298155d59cb758fe7d34" +sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -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 = "89f2c7495cfb0fc9d36d35b95c056dbeda18ccae521250babac719ff5df62d89" +sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index d81487afb..0d1cfee90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -183,6 +183,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-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. - **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6148c2748..855361f6f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1516,6 +1516,13 @@ impl DocView { } } + /// A zoom typed as a percentage ("150", "150%", " 62.5 % ") as a zoom factor; `None` unless it is a + /// finite, positive number. `set_zoom` clamps it to the supported range. + pub fn parse_zoom_percent(typed: &str) -> Option { + let percent = typed.trim().trim_end_matches('%').trim_end().parse::().ok()?; + (percent.is_finite() && percent > 0.0).then_some(percent / 100.0) + } + /// Standard zoom steps (as in Acrobat's zoom menu). pub fn zoom_step(&mut self, up: bool) { const STEPS: [f32; 17] = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0, 16.0, 32.0]; @@ -5156,6 +5163,16 @@ mod tests { use super::*; + #[test] + fn typed_zoom_percentages_parse() { + for (typed, zoom) in [("150", 1.5), ("150%", 1.5), (" 62.5 % ", 0.625), ("6400%", 64.0)] { + assert_eq!(DocView::parse_zoom_percent(typed), Some(zoom), "{typed}"); + } + for typed in ["", "%", "abc", "0", "-50", "NaN", "inf", "1e40", "150%%x", "%150"] { + assert_eq!(DocView::parse_zoom_percent(typed), None, "{typed}"); + } + } + /// A rotated (landscape) page is drawn as wide as the cell allows and centred in the picture /// area, as Acrobat draws it, not as a small strip pinned to the top; a portrait page still /// fills the area's height; neither covers the checkbox corner or the label below. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 84e62622f..fb9e0a7c0 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -694,10 +694,36 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } - let zoom = format!("{:.0}%", view.zoom * 100.0); - ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { - ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); - }); + let id = egui::Id::new("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .horizontal_align(Align::Center) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); + } + } ui.add_space(6.0); if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { view.step_page(true); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 246a26fa6..8697887e1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -162,6 +162,7 @@ Next page الصفحة التالية Previous page الصفحة السابقة Current page — type a page number or label (such as iv) and press Enter الصفحة الحالية — اكتب رقم صفحة أو تسميتها (مثل iv) ثم اضغط Enter + Zoom — type a percentage and press Enter التكبير — اكتب نسبة مئوية واضغط Enter Welcome to PdfCraft مرحبًا بك في PdfCraft An open-source PDF workbench — local, private, and scriptable. منضدة عمل مفتوحة المصدر لملفات PDF — محلية وخاصة وقابلة للبرمجة. Join the ArtCraft community انضم إلى مجتمع ArtCraft diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a39489aed..813a2c7ae 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1123,6 +1123,7 @@ Next page Следваща страница Previous page Предишна страница Current page — type a page number or label (such as iv) and press Enter Текуща страница — въведете номер или етикет на страница (например iv) и натиснете Enter + Zoom — type a percentage and press Enter Мащаб — въведете процент и натиснете Enter Open a PDF ({key}) Отвори PDF файл ({key}) Dark gray theme Тъмносива тема Light theme Светла тема diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b7c677e0..993632552 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -279,6 +279,7 @@ key Space Mezerník Next page Další stránka Previous page Předchozí stránka Current page — type a page number or label (such as iv) and press Enter Aktuální stránka — zadejte číslo nebo označení stránky (třeba iv) a stiskněte Enter + Zoom — type a percentage and press Enter Lupa — zadejte procenta a stiskněte Enter Welcome to PdfCraft Vítá vás PdfCraft An open-source PDF workbench — local, private, and scriptable. Open-source dílna pro PDF — lokální, soukromá a skriptovatelná. Join the ArtCraft community Připojte se ke komunitě ArtCraft diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c98c40561..4d9207eb9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -135,6 +135,7 @@ Next page Nächste Seite Previous page Vorherige Seite Current page — type a page number or label (such as iv) and press Enter Aktuelle Seite – gib eine Seitenzahl oder -beschriftung ein (z. B. iv) und drücke die Eingabetaste + Zoom — type a percentage and press Enter Zoom – gib einen Prozentwert ein und drücke die Eingabetaste Welcome to PdfCraft Willkommen bei PdfCraft An open-source PDF workbench — local, private, and scriptable. Eine Open-Source-PDF-Werkbank – lokal, privat und skriptfähig. Join the ArtCraft community Tritt der ArtCraft-Community bei diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9be3e19c2..06fe2b540 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -282,6 +282,7 @@ Flatten Fill & Sign when saving Ενσωμάτωση συμπληρώσεων και υπογραφών κατά την αποθήκευση Text, marks and signatures become part of the page. Other comments stay editable. Το κείμενο, τα σημάδια και οι υπογραφές γίνονται μέρος της σελίδας. Τα υπόλοιπα σχόλια παραμένουν επεξεργάσιμα. Current page — type a page number or label (such as iv) and press Enter Τρέχουσα σελίδα — πληκτρολογήστε αριθμό ή ετικέτα σελίδας (π.χ. iv) και πατήστε Enter + Zoom — type a percentage and press Enter Ζουμ — πληκτρολογήστε ένα ποσοστό και πατήστε Enter Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Τα αρχεία που ανοίγετε στο PdfCraft εμφανίζονται εδώ. Σύρετε ένα PDF οπουδήποτε για να το ανοίξετε. Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Τα σενάρια εκτελούνται σε απομονωμένο περιβάλλον χωρίς πρόσβαση σε αρχεία ή δίκτυο. Με απενεργοποιημένη τη JavaScript, οι τυπικές λειτουργίες μορφοποίησης, επικύρωσης και υπολογισμού του Acrobat εξακολουθούν να λειτουργούν. Move up Μετακίνηση πάνω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6a48611a3..79c668992 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -158,6 +158,7 @@ Next page Página siguiente Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página actual: escribe un número o una etiqueta de página (por ejemplo, iv) y pulsa Intro + Zoom — type a percentage and press Enter Zoom: escribe un porcentaje y pulsa Intro Welcome to PdfCraft Te damos la bienvenida a PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco de trabajo PDF de código abierto: local, privado y automatizable. Join the ArtCraft community Únete a la comunidad de ArtCraft diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0de88637a..9bdcf0bc8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -135,6 +135,7 @@ Next page Page suivante Previous page Page précédente Current page — type a page number or label (such as iv) and press Enter Page actuelle — saisissez un numéro ou une étiquette de page (comme iv) et appuyez sur Entrée + Zoom — type a percentage and press Enter Zoom — saisissez un pourcentage et appuyez sur Entrée Welcome to PdfCraft Bienvenue dans PdfCraft An open-source PDF workbench — local, private, and scriptable. Un atelier PDF open source — local, privé et automatisable. Join the ArtCraft community Rejoindre la communauté ArtCraft diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2d7dd82a1..d537677c7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -133,6 +133,7 @@ Next page Következő oldal Previous page Előző oldal Current page — type a page number or label (such as iv) and press Enter Aktuális oldal — írj be egy oldalszámot vagy címkét (például iv), majd nyomd meg az Entert + Zoom — type a percentage and press Enter Nagyítás – írjon be egy százalékértéket, és nyomja meg az Entert Welcome to PdfCraft Üdvözöljük a PdfCraftban An open-source PDF workbench — local, private, and scriptable. Nyílt forráskódú PDF-munkapad — helyi, privát és parancsfájlokkal vezérelhető. Join the ArtCraft community Csatlakozz az ArtCraft közösséghez diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 169533374..35cba7a41 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -159,6 +159,7 @@ Next page Pagina successiva Previous page Pagina precedente Current page — type a page number or label (such as iv) and press Enter Pagina corrente: digita un numero o un'etichetta di pagina (ad esempio iv) e premi Invio + Zoom — type a percentage and press Enter Zoom: digita una percentuale e premi Invio Welcome to PdfCraft Benvenuto in PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco di lavoro PDF open source: locale, riservato e programmabile. Join the ArtCraft community Unisciti alla community di ArtCraft diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c86303174..e845a736d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -162,6 +162,7 @@ Next page 次のページ Previous page 前のページ Current page — type a page number or label (such as iv) and press Enter 現在のページ:ページ番号またはラベル(例:iv)を入力して Enter + Zoom — type a percentage and press Enter ズーム — パーセンテージを入力して Enter キーを押します Welcome to PdfCraft PdfCraft へようこそ An open-source PDF workbench — local, private, and scriptable. ローカルで処理し、プライバシーを守る、スクリプト対応のオープンソース PDF ツール。 Join the ArtCraft community ArtCraft コミュニティに参加 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 64a4ea26f..9acb4ec1b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -123,6 +123,7 @@ Next page Келесі бет Previous page Алдыңғы бет Current page — type a page number or label (such as iv) and press Enter Ағымдағы бет — бет нөмірін немесе белгісін (мысалы, iv) теріңіз де, Enter пернесін басыңыз + Zoom — type a percentage and press Enter Масштаб — пайызды енгізіп, Enter пернесін басыңыз Welcome to PdfCraft PdfCraft-ге қош келдіңіз An open-source PDF workbench — local, private, and scriptable. Ашық бастапқы кодты PDF жұмыс орны — жергілікті, жеке әрі скрипттерді қолдайтын. Join the ArtCraft community ArtCraft қауымдастығына қосылыңыз diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d98befcf4..d1dfc3304 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -431,6 +431,7 @@ Current page Dabartinis puslapis Current page ({n}) Dabartinis puslapis ({n}) Current page — type a page number or label (such as iv) and press Enter Dabartinis puslapis — įveskite puslapio numerį arba žymą (pvz., iv) ir paspauskite „Enter“ + Zoom — type a percentage and press Enter Mastelis – įveskite procentus ir paspauskite Enter Custom Pasirinktinis Custom scale: Pasirinktinis mastelis: Custom text: Pasirinktinis tekstas: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e8df52794..c432a6a92 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -158,6 +158,7 @@ Next page Następna strona Previous page Poprzednia strona Current page — type a page number or label (such as iv) and press Enter Bieżąca strona — wpisz numer lub etykietę strony (np. iv) i naciśnij Enter + Zoom — type a percentage and press Enter Powiększenie — wpisz wartość procentową i naciśnij Enter Welcome to PdfCraft Witamy w PdfCraft An open-source PDF workbench — local, private, and scriptable. Otwarty zestaw narzędzi do PDF — lokalny, prywatny i obsługujący skrypty. Join the ArtCraft community Dołącz do społeczności ArtCraft diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dbefb9d04..89ff80f9d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1067,6 +1067,7 @@ Next page Próxima página Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página atual — digite o número da página ou rótulo (como iv) e pressione Enter + Zoom — type a percentage and press Enter Zoom — digite uma porcentagem e pressione Enter Open a PDF ({key}) Abrir um PDF ({key}) Dark gray theme Tema cinza escuro Light theme Tema claro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d5cd9d6cb..73f21aa0d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -155,6 +155,7 @@ Next page Следующая страница Previous page Предыдущая страница Current page — type a page number or label (such as iv) and press Enter Текущая страница — введите номер или метку страницы (например, iv) и нажмите Enter + Zoom — type a percentage and press Enter Масштаб — введите процент и нажмите Enter Welcome to PdfCraft Добро пожаловать в PdfCraft An open-source PDF workbench — local, private, and scriptable. Открытый PDF-редактор — локальный, приватный и программируемый. Join the ArtCraft community Присоединяйтесь к сообществу ArtCraft diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 560450c25..dd169bc96 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -519,6 +519,7 @@ Current page Trenutna stran Current page ({n}) Trenutna stran ({n}) Current page — type a page number or label (such as iv) and press Enter Trenutna stran — vnesite številko ali oznako strani (npr. iv) in pritisnite Enter + Zoom — type a percentage and press Enter Povečava – vnesite odstotek in pritisnite Enter Custom Po meri Custom scale: Merilo po meri: Custom stamps arrive on the web with file pickers for images Lastni žigi bodo v spletni različici na voljo z izbirniki slikovnih datotek diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index c3900c825..47aee36f8 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -139,6 +139,7 @@ comment menu Edit సవరించు Next page తదుపరి పేజీ Previous page మునుపటి పేజీ Current page — type a page number or label (such as iv) and press Enter ప్రస్తుత పేజీ — పేజీ సంఖ్యను లేదా లేబుల్‌ను (iv వంటిది) టైప్ చేసి Enter నొక్కండి + Zoom — type a percentage and press Enter జూమ్ — శాతాన్ని టైప్ చేసి Enter నొక్కండి Welcome to PdfCraft PdfCraftకు స్వాగతం An open-source PDF workbench — local, private, and scriptable. ఓపెన్-సోర్స్ PDF వర్క్‌బెంచ్ — మీ కంప్యూటర్‌లోనే నడుస్తుంది, గోప్యంగా ఉంటుంది, స్క్రిప్ట్‌లతో ఆటోమేట్ చేయవచ్చు. Join the ArtCraft community ArtCraft కమ్యూనిటీలో చేరండి diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bec543b02..95ca0b608 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -120,6 +120,7 @@ Next page Sonraki sayfa Previous page Önceki sayfa Current page — type a page number or label (such as iv) and press Enter Geçerli sayfa — bir sayfa numarası veya etiketi (örneğin iv) yazıp Enter'a basın + Zoom — type a percentage and press Enter Yakınlaştırma — bir yüzde yazıp Enter tuşuna basın Welcome to PdfCraft PdfCraft'a Hoş Geldiniz An open-source PDF workbench — local, private, and scriptable. Açık kaynaklı bir PDF çalışma alanı — yerel, gizli ve komut dosyalarıyla yönetilebilir. Join the ArtCraft community ArtCraft topluluğuna katılın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 379e47317..7993a0618 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -160,6 +160,7 @@ Next page Наступна сторінка Previous page Попередня сторінка Current page — type a page number or label (such as iv) and press Enter Поточна сторінка — введіть номер або позначку сторінки (наприклад iv) і натисніть Enter + Zoom — type a percentage and press Enter Масштаб — введіть відсоток і натисніть Enter Welcome to PdfCraft Вітаємо в PdfCraft An open-source PDF workbench — local, private, and scriptable. Відкритий інструментарій для PDF — локальний, приватний і з підтримкою сценаріїв. Join the ArtCraft community Приєднатися до спільноти ArtCraft diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b68d6f61e..bae599bf6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -15,6 +15,7 @@ Zoom Thu phóng Actual size Kích thước thực Zoom to page level Thu phóng vừa trang + Zoom — type a percentage and press Enter Thu phóng — nhập tỷ lệ phần trăm rồi nhấn Enter Fit to width Vừa chiều rộng Display theme Chủ đề hiển thị Side panels Bảng bên diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1f37b431d..1ddecc655 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1130,6 +1130,7 @@ Next page 下一页 Previous page 上一页 Current page — type a page number or label (such as iv) and press Enter 当前页 — 输入页码或标签(如 iv)后按 Enter + Zoom — type a percentage and press Enter 缩放 — 输入百分比并按 Enter Open a PDF ({key}) 打开 PDF ({key}) Dark gray theme 深灰色主题 Light theme 浅色主题 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ded4ad904..bb5ec3649 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1074,6 +1074,7 @@ Next page 下一頁 Previous page 上一頁 Current page — type a page number or label (such as iv) and press Enter 目前頁面:輸入頁碼或頁面標籤(例如 iv),再按 Enter + Zoom — type a percentage and press Enter 縮放 — 輸入百分比並按 Enter Open a PDF ({key}) 開啟 PDF({key}) Dark gray theme 深灰色主題 Light theme 淺色主題 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e173b93d4..725bcad4b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2565,7 +2565,7 @@ impl PdfCraftApp { self.palette_query = value.to_string(); } ("page", Some(v)) => v.go_to_page(value.parse::().map_err(|e| e.to_string())?.saturating_sub(1)), - ("zoom", Some(v)) => v.set_zoom(value.trim_end_matches('%').parse::().map_err(|e| e.to_string())? / 100.0), + ("zoom", Some(v)) => v.set_zoom(DocView::parse_zoom_percent(value).ok_or("zoom must be a percentage above 0")?), ("layout", Some(v)) => { let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?; v.set_display(layout, continuous); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 5ea5452fa..c30f081e4 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -617,6 +617,35 @@ fn default_zoom_is_used_for_new_documents() { assert_eq!(fit_zoom(&fresh, 0), (Fit::None, 1.5)); } +#[test] +fn the_rail_zoom_field_sets_a_typed_zoom() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + use pdfcraft_ui_egui::canvas::Fit; + let mut h = harness(&[]); + let type_zoom = |h: &mut Harness<'static, PdfCraftApp>, typed: &str, key: Key| { + h.get_by_role_and_label(Role::TextInput, "Zoom").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Zoom").type_text(typed); + h.run_steps(1); + h.key_press(key); + h.run_steps(3); + }; + type_zoom(&mut h, "150", Key::Enter); + assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 1.5)); + type_zoom(&mut h, "62.5 %", Key::Enter); + assert_eq!(h.state().views[0].zoom, 0.625); + for (typed, key) in [("abc", Key::Enter), ("0", Key::Enter), ("300", Key::Escape)] { + type_zoom(&mut h, typed, key); + assert_eq!(h.state().views[0].zoom, 0.625, "{typed}"); + } + type_zoom(&mut h, "9000", Key::Enter); + assert_eq!(h.state().views[0].zoom, 64.0); + h.state_mut().views[0].set_zoom(2.0); + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Zoom").value().as_deref(), Some("200%")); +} + #[test] fn preferences_set_the_default_zoom() { use egui_kittest::kittest::Queryable; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 47ea3f4e1..3a2569882 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -950,8 +950,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"] -notes = "PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" +evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/view.rs::the_rail_zoom_field_sets_a_typed_zoom"] +notes = "Typed into the right rail's zoom field (150, 150% or 62.5 %, Enter applies, Escape discards, clamped to 8–6400%) or set through the control channel's zoom option; no preset dropdown in the field yet. PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" [[feature]] id = "view.zoom-actual-size" From 1952a88801252fe4d9ff106a780d9e86930b9088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?enes=20y=C4=B1ld=C4=B1z?= <121250962+enesoglu@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:54:00 +0300 Subject: [PATCH 12/12] fix(cli): use laid-out pages for dynamic XFA forms (#2813) Fixes #1645. For a dynamic XFA form, `info` reported the placeholder page and field counts, and `text` and `render` read the placeholder pages, while the app and the automation tools lay the template out. The CLI now opens dynamic XFA forms through the engine session and uses its laid-out document for page and field metadata, text and pixels. `info` keeps the input's file size and also reports the original counts as `pdf_pages` and `acroform_fields`; `text` checks the copy permission before extracting. The input file is never written. Checks run (rustc 1.99.0): cargo fmt --check; cargo clippy -p pdfcraft-cli --all-targets -- -D warnings; cargo test -p pdfcraft-cli; cargo xtask layers; cargo xtask assets. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + apps/pdfcraft-cli/src/main.rs | 48 ++++++++---- apps/pdfcraft-cli/tests/xfa.rs | 136 +++++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- parity/acrobat-features.toml | 4 +- 6 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/xfa.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0d1cfee90..a8aefb06d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,6 +166,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. +- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. - **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 0e24800d3..119aaf4ac 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -298,14 +298,21 @@ fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); - let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); // Dynamic XFA forms: say what laying the template out gives (what the app shows). let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) .then(|| { let mut s = pdfcraft_engine::Session::new(); let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; let d = s.get(id)?; - d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })) + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) }) .flatten(); let json = serde_json::json!({ @@ -313,6 +320,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "pdf_version": info.pdf_version, "file_size": info.file_size, "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, @@ -348,7 +356,19 @@ fn text(args: &[String]) -> Result<(), CliError> { } } let password = flag(args, "--password"); - let bytes = read(path)?; + let mut bytes = read(path)?; + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); // A document the renderer could not open (wrong or missing password, unparseable file) has // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask @@ -359,15 +379,6 @@ fn text(args: &[String]) -> Result<(), CliError> { return Err("text: the document could not be parsed".into()); } } - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - } let pages: Vec = match selected_page { Some(page) => vec![page], None => (0..r.page_count()).collect(), @@ -588,10 +599,15 @@ fn render(args: &[String]) -> Result<(), CliError> { // The app renders a display copy that draws comments missing their /AP streams; the CLI // must agree with it (and with independent viewers), while keeping the file it reads intact. let bytes = read(path)?; - let mut r = PageRenderer::new( - pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), - RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, - ); + let password = flag(args, "--password"); + let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() }); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); if let Some(e) = p.error { return Err(e.into()); diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/xfa.rs new file mode 100644 index 000000000..1b5ff7bc9 --- /dev/null +++ b/apps/pdfcraft-cli/tests/xfa.rs @@ -0,0 +1,136 @@ +//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645). + +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::Arc; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn fixture(test: &str) -> PathBuf { + let xdp = r#" +"#; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("form.pdf"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn success(output: &Output) { + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +#[test] +fn info_reports_the_laid_out_pages_and_fields() { + let path = fixture("info"); + let original = std::fs::read(&path).unwrap(); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages"); + assert_eq!(info["pages"], 2, "top-level page count must agree with the app"); + assert_eq!(info["fields"], 1); + assert_eq!(info["pdf_pages"], 1); + assert_eq!(info["acroform_fields"], 0); + assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0])); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input"); +} + +#[test] +fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() { + let path = fixture("text"); + let output = Command::new(BIN).arg("text").arg(&path).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}"); + let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}"); +} + +#[test] +fn render_matches_the_engine_layout_on_the_second_page() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + let path = fixture("render"); + let original = std::fs::read(&path).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap(); + let doc = session.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page"))); + let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default()); + let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(expected.error.is_none(), "{:?}", expected.error); + assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink"); + let out = path.with_extension("pam"); + let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap(); + success(&output); + let actual = std::fs::read(out).unwrap(); + let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height); + assert!(actual.starts_with(header.as_bytes())); + assert_eq!(&actual[header.len()..], &expected.rgba[..]); + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn a_laid_out_form_keeps_the_original_copy_permissions() { + let path = fixture("permissions"); + let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "openme", + owner_password: "boss", + permissions: -17, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + std::fs::write(&path, &original).unwrap(); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying")); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap(); + success(&output); + assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page")); + let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["pages"], 2); + assert_eq!(info["encrypted"], true); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 798033793..91742b2c4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -138,7 +138,7 @@ total matches the parity estimate. | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
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 | +| 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
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | | 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. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. 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). | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7698baee..9119063e2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ full parity in that area. | C Content editing | 61 | 29 | 7 | 53.3% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) | | D Organize pages | 76 | 45 | 3 | 61.2% | 70% | 12–25 | Organize grid, insert/extract/replace/split/combine with links, fields, layers and bookmarks carried over; page labels, boxes, bookmarks from structure. Missing: transitions, attachments editing, portfolios, struct-tree merge, field-name conflicts (destination and annotation fixes #817, #818, #821 shipped) | | E Comments and review | 65 | 46 | 9 | 77.7% | 70% | 12–25 | Every markup type with appearances, replies, status, filters, stamps (custom, dynamic), XFDF/FDF, summaries, flatten, 2D measuring. Missing: rich-text runs, popup windows, summary layouts with pages, per-type hiding (XFDF fidelity fixes #807, #809, #819 shipped) | -| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | +| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3a2569882..3e2b2efae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc"