From 3ec7ce8aae949edd3d553fdbe24a237a14600512 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 15 Oct 2026 10:41:14 +0200 Subject: [PATCH 01/43] M3: All tools expands to a full, searchable view An expand button at the top-left of the All tools sidebar opens a view that fills the workspace, like Acrobat's tools page: every tool as a tile, filed under five categories (Create & Edit, Review & Compare, Forms & Signatures, Protect & Standardize, Customize & Automate), with a search field that matches tool names and the items inside each tool, in English or the UI language. Choosing a tile opens that tool's panel; Collapse, Esc or another workspace puts the view away. `--tools full` opens it from the command line and the control channel. The categories live in the engine catalogue (TOOL_CATEGORIES), with a test that every tool group is in exactly one. Six new strings are translated in all 22 catalogues. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 ++++---- crates/engine/src/catalog.rs | 29 +++++ crates/ui-egui/src/all_tools_ui.rs | 158 ++++++++++++++++++++++++++++ crates/ui-egui/src/home.rs | 2 +- crates/ui-egui/src/i18n/ar.tsv | 6 ++ crates/ui-egui/src/i18n/bg.tsv | 6 ++ crates/ui-egui/src/i18n/cs.tsv | 6 ++ crates/ui-egui/src/i18n/de.tsv | 6 ++ crates/ui-egui/src/i18n/el.tsv | 6 ++ crates/ui-egui/src/i18n/es.tsv | 6 ++ crates/ui-egui/src/i18n/fr.tsv | 6 ++ crates/ui-egui/src/i18n/hu.tsv | 6 ++ crates/ui-egui/src/i18n/it.tsv | 6 ++ crates/ui-egui/src/i18n/ja.tsv | 6 ++ crates/ui-egui/src/i18n/kk.tsv | 6 ++ crates/ui-egui/src/i18n/lt.tsv | 6 ++ crates/ui-egui/src/i18n/mod.rs | 3 + crates/ui-egui/src/i18n/pl.tsv | 6 ++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++ crates/ui-egui/src/i18n/ru.tsv | 6 ++ crates/ui-egui/src/i18n/sl.tsv | 6 ++ crates/ui-egui/src/i18n/te.tsv | 6 ++ crates/ui-egui/src/i18n/tr.tsv | 6 ++ crates/ui-egui/src/i18n/uk.tsv | 6 ++ crates/ui-egui/src/i18n/vi.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++ crates/ui-egui/src/lib.rs | 21 +++- crates/ui-egui/src/panels.rs | 10 +- crates/ui-egui/tests/ui.rs | 44 ++++++++ 30 files changed, 417 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/all_tools_ui.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f3791f719..180bbd6ba8 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 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" +sha256 = "d779dbb265c576d68cd804d0a0505ac5821d74f2d4e5afb663516d8801e0fbbc" # 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 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223" +sha256 = "69a7a36b9b8fbe6cd04d7f287a77deb01c243cc0f1a438511cf35748dde6b3df" [[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 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2" +sha256 = "12a568bf7a626ff3e34009150894eabe3c0927589756d8635fc9bae19638a512" [[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 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536" +sha256 = "0fd03fafffc2d75bf484186a7dd6ab15ab95d79448e0e798f57519515d73ac4e" [[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 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7" +sha256 = "d3292e186768f75b9cb87097688ff4909c26c9810a7e6542c127aad9fbce5af3" [[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 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679" +sha256 = "14c9f2ba61ffc925b01e6bf31c7f7761fb5e7adcda1751f6768829341a8e3757" [[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 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a" +sha256 = "d2dc6bd5de197eff60a440a05c58c6b7307009ecb08ddf8fbb31ff616633e36e" [[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 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69" +sha256 = "b6ffc5eda71ea13eaea79f5f3368811cb317f0a43e6f6be167bb4742d8e1ae77" [[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 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" +sha256 = "37b60dda614ebc0e2b141cb27c9b2b7d13b49b347c1a5acb4d395147fbbdd246" [[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 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5" +sha256 = "0055dfb04a35abd2c8da01bf3836855074e17bbd2b6332b2d1cecd16fd816938" [[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 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" +sha256 = "e1c4801f9633f5873f5dc07fa6de01375369b636282fa22e8cd223adb9737b42" [[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 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363" +sha256 = "36bd183e5bb36e76e2eb40ac68d06f2d0dcd962b42998917ba3ad5e7d8ca2b19" [[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 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643" +sha256 = "c10dab015d7cabc571dd2a462a0ca22a1b1461b9b747c4998e7a7e4e1f9b6b71" [[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 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e" +sha256 = "7b1db4f5f3ce50c8b995037d84cce86088e3af17942b2e1d0197234a2454ecdd" [[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 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578" +sha256 = "3ef5f6e5c28c8060017b1219d0ae3fe6308eaa4f69baf821816da1ac2b4468cd" [[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 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf" +sha256 = "d2b91bcf778610861b515865339a487f279f68188617fd2729e0d8d8455d7a2b" [[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 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2" +sha256 = "b3a0c1563ba9c67ffbebf805769bae9c5a809cd14fd37a0441a30e19cacb28c8" [[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 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25" +sha256 = "15dde8ef0f0d20a6e99b82c7f5297b41130e9f0d8f11494fcdc09c69bcbfb43c" [[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 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339" +sha256 = "9d879f455313d528d2d8c91e080a1fa199f50d901c7691f7b6dc3f15be73fd1a" [[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 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5" +sha256 = "b300a148c46c122b84ee9b978979ad41c3b4fbb220a488989bb991e0062d9885" [[asset]] @@ -240,7 +240,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3" +sha256 = "4c09041b54acbdb7c5fe16409b2fa699d260200fec8600000d668c9af7636f8c" [[asset]] path = "assets/icons/accessibility.svg" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c" +sha256 = "bb3a8708b1a2f72782ab41b0ae43e902f089caffd545004f5d1b2112d20df78c" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b46d88acf1..7a63beef89 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -547,6 +547,23 @@ pub fn group(id: &str) -> Option<&'static ToolGroup> { TOOL_GROUPS.iter().find(|g| g.id == id) } +/// A heading of the expanded All tools view, as Acrobat files its tools under "Create & Edit", +/// "Forms & Signatures" and so on. Every group belongs to exactly one category. +#[derive(Clone, Copy, Debug)] +pub struct ToolCategory { + pub label: &'static str, + /// [`ToolGroup::id`]s, in display order. + pub groups: &'static [&'static str], +} + +pub static TOOL_CATEGORIES: &[ToolCategory] = &[ + ToolCategory { label: "Create & Edit", groups: &["create", "edit", "export", "combine", "organize", "scan", "compress"] }, + ToolCategory { label: "Review & Compare", groups: &["comment", "stamp", "compare", "measure"] }, + ToolCategory { label: "Forms & Signatures", groups: &["fill_sign", "form", "certificate"] }, + ToolCategory { label: "Protect & Standardize", groups: &["protect", "redact", "accessibility", "standards", "print_production"] }, + ToolCategory { label: "Customize & Automate", groups: &["actions", "javascript", "search_index", "ai"] }, +]; + #[cfg(test)] mod tests { use super::*; @@ -561,6 +578,18 @@ mod tests { } } + #[test] + fn every_group_is_in_exactly_one_category() { + let mut seen = HashSet::new(); + for c in TOOL_CATEGORIES { + for id in c.groups { + assert!(group(id).is_some(), "{} lists unknown group {id}", c.label); + assert!(seen.insert(*id), "{id} is in two categories"); + } + } + assert_eq!(seen.len(), TOOL_GROUPS.len(), "a group is in no category"); + } + #[test] fn commands_are_namespaced() { for g in TOOL_GROUPS { diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs new file mode 100644 index 0000000000..e46ec6c723 --- /dev/null +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -0,0 +1,158 @@ +//! The expanded All tools view: every tool, filed under Acrobat-style categories, with a search +//! field, filling the workspace instead of the sidebar. The sidebar's expand button opens it; +//! its collapse button, Esc or choosing a tool closes it again. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::catalog::{self, Availability, TOOL_CATEGORIES, ToolGroup}; + +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, icons, widgets}; + +const TILE: egui::Vec2 = vec2(148.0, 118.0); +/// Item names a tile lists when the search found the tool by them. +const MATCHES_SHOWN: usize = 2; + +/// Whether `group` matches `query` (already trimmed and lower-cased), in English or the UI +/// language. `None` is no match; otherwise the items that matched, empty when the tool's own +/// name did. +fn matches(query: &str, group: &ToolGroup) -> Option> { + let hit = |label: &str| label.to_lowercase().contains(query) || tl!(label).to_lowercase().contains(query); + if query.is_empty() || hit(group.label) { + return Some(Vec::new()); + } + let items: Vec<_> = group.sections.iter().flat_map(|s| s.items.iter()).map(|i| i.label).filter(|l| hit(l)).collect(); + (!items.is_empty()).then_some(items) +} + +pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.all_tools_full = false; + return; + } + let mut chosen = None; + egui::Frame::NONE.fill(t.panel).show(ui, |ui| { + ui.set_min_size(ui.available_size()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 28, top: 20, bottom: 12 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("All tools")).font(theme::semibold(22.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "minimize-2", 28.0, false, tl!("Collapse")).clicked() { + app.all_tools_full = false; + } + }); + }); + ui.add_space(10.0); + let field = ui.add( + widgets::line(&mut app.all_tools_query).hint_text(tl!("Search tools and commands…")).desired_width(ui.available_width().min(520.0)), + ); + // Typing goes straight to the search, as in Acrobat's tools view. + if !ui.memory(|m| m.focused().is_some()) { + field.request_focus(); + } + }); + ui.separator(); + let query = app.all_tools_query.trim().to_lowercase(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 28, top: 8, bottom: 28 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + let mut any = false; + for category in TOOL_CATEGORIES { + let found: Vec<_> = + category.groups.iter().filter_map(|id| catalog::group(id)).filter_map(|g| matches(&query, g).map(|m| (g, m))).collect(); + if found.is_empty() { + continue; + } + any = true; + ui.add_space(12.0); + ui.label(egui::RichText::new(tl!(category.label)).font(theme::semibold(16.0)).color(t.text)); + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(14.0, 14.0); + for (g, items) in found { + if tile(ui, &t, g, &items).clicked() { + chosen = Some(g.id); + } + } + }); + ui.add_space(8.0); + } + if !any { + ui.add_space(24.0); + ui.label(egui::RichText::new(tl!("No tools match your search.")).color(t.text_muted)); + } + }); + }); + }); + if let Some(id) = chosen { + app.all_tools_full = false; + app.left_open = true; + app.select_tool(id); + } +} + +fn tile(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, items: &[&'static str]) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + let press = theme::Press::track(ui, &resp); + crate::home::card_lift(ui, t, rect, press); + ui.painter().rect(rect, CornerRadius::same(10), press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.divider), egui::StrokeKind::Inside); + let body = rect.translate(press.offset()); + let ready = g.availability == Availability::Ready; + let hue = Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]); + icons::paint(ui, Rect::from_center_size(body.center_top() + vec2(0.0, 34.0), vec2(32.0, 32.0)), g.icon, 30.0, hue); + let mut job = + egui::text::LayoutJob::simple(tl!(g.label).to_owned(), theme::medium(13.0), if ready { t.text } else { t.text_muted }, body.width() - 16.0); + job.halign = egui::Align::Center; + job.wrap.max_rows = 2; + let title = ui.fonts_mut(|f| f.layout_job(job)); + let mut elided = title.elided; + let title_h = title.size().y; + ui.painter().galley(body.center_top() + vec2(0.0, 60.0), title, t.text); + // What found the tool, or else whether it works today. + let note = if items.is_empty() { + match g.availability { + Availability::Ready => String::new(), + Availability::Planned(m) => format!("{} · {m}", tl!("Planned")), + Availability::Provider => g.badge.map(|b| tl!(b).to_owned()).unwrap_or_default(), + } + } else { + items.iter().take(MATCHES_SHOWN).map(|l| tl!(l)).collect::>().join(" · ") + }; + if !note.is_empty() { + let color = if items.is_empty() { t.text_faint } else { t.accent_text }; + let mut job = egui::text::LayoutJob::simple_singleline(note.clone(), theme::regular(11.0), color); + job.halign = egui::Align::Center; + job.wrap = egui::text::TextWrapping { max_width: body.width() - 16.0, max_rows: 1, break_anywhere: true, ..Default::default() }; + let galley = ui.fonts_mut(|f| f.layout_job(job)); + elided |= galley.elided; + ui.painter().galley(body.center_top() + vec2(0.0, 64.0 + title_h), galley, color); + } + if let Some(b) = g.badge.filter(|_| g.availability == Availability::Ready) { + let font = theme::semibold(9.5); + let w = widgets::text_width(ui, tl!(b), &font); + let r = Rect::from_min_size(body.right_top() + vec2(-w - 18.0, 8.0), vec2(w + 10.0, 16.0)); + ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new); + ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!(b), font, Color32::WHITE); + } + let resp = if elided { resp.on_hover_text(format!("{}\n{note}", tl!(g.label))) } else { resp }; + theme::hand(resp) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn group(id: &str) -> &'static ToolGroup { + catalog::group(id).expect("catalogue group") + } + + #[test] + fn search_matches_tool_names_and_their_items() { + assert_eq!(matches("", group("organize")), Some(Vec::new()), "an empty query shows everything"); + assert_eq!(matches("organize", group("organize")), Some(Vec::new()), "the tool's own name"); + let rotate = matches("rotate", group("organize")).expect("Organize pages lists Rotate"); + assert!(!rotate.is_empty() && rotate.iter().all(|l| l.to_lowercase().contains("rotate")), "{rotate:?}"); + assert_eq!(matches("no such tool anywhere", group("organize")), None); + } +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 3e70f34e42..42a21900d5 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -10,7 +10,7 @@ use crate::{PdfCraftApp, icons, panels::human_size, widgets}; const RECOMMENDED: [&str; 5] = ["organize", "comment", "form", "edit", "protect"]; /// A soft shadow that rises on hover and settles when the card is pressed. -fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) { +pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) { let lift = press.hover * (1.0 - press.down); if lift > 0.04 { let shadow = egui::Shadow { offset: [0, 3], blur: (14.0 * lift) as u8, spread: 0, color: t.page_shadow.gamma_multiply(0.85 * lift) }; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 01bb1679e4..1915ee5fc6 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2706,3 +2706,9 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Create & Edit الإنشاء والتحرير + Review & Compare المراجعة والمقارنة + Forms & Signatures النماذج والتوقيعات + Protect & Standardize الحماية والتوحيد القياسي + Customize & Automate التخصيص والأتمتة + No tools match your search. لا توجد أدوات تطابق بحثك. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8f2fea70e0..57bbffaabb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2391,3 +2391,9 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Create & Edit Създаване и редактиране + Review & Compare Преглед и сравнение + Forms & Signatures Формуляри и подписи + Protect & Standardize Защита и стандартизация + Customize & Automate Персонализиране и автоматизация + No tools match your search. Няма инструменти, отговарящи на търсенето. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bad5c497e8..c52ec8b20d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2502,3 +2502,9 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + Create & Edit Vytváření a úpravy + Review & Compare Revize a porovnání + Forms & Signatures Formuláře a podpisy + Protect & Standardize Ochrana a standardizace + Customize & Automate Přizpůsobení a automatizace + No tools match your search. Hledání neodpovídá žádný nástroj. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b5baed7ba1..94863e0743 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2429,3 +2429,9 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + Create & Edit Erstellen & Bearbeiten + Review & Compare Prüfen & Vergleichen + Forms & Signatures Formulare & Signaturen + Protect & Standardize Schützen & Standardisieren + Customize & Automate Anpassen & Automatisieren + No tools match your search. Keine Werkzeuge entsprechen Ihrer Suche. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 57de6eef07..b22b3cb2a6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2438,3 +2438,9 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Create & Edit Δημιουργία και επεξεργασία + Review & Compare Αναθεώρηση και σύγκριση + Forms & Signatures Φόρμες και υπογραφές + Protect & Standardize Προστασία και τυποποίηση + Customize & Automate Προσαρμογή και αυτοματοποίηση + No tools match your search. Κανένα εργαλείο δεν ταιριάζει με την αναζήτησή σας. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 548daf0caa..332cd43f06 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2568,3 +2568,9 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + Create & Edit Crear y editar + Review & Compare Revisar y comparar + Forms & Signatures Formularios y firmas + Protect & Standardize Proteger y estandarizar + Customize & Automate Personalizar y automatizar + No tools match your search. Ninguna herramienta coincide con la búsqueda. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c082789bc1..0e69171c6f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2428,3 +2428,9 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page + Create & Edit Créer et modifier + Review & Compare Réviser et comparer + Forms & Signatures Formulaires et signatures + Protect & Standardize Protéger et normaliser + Customize & Automate Personnaliser et automatiser + No tools match your search. Aucun outil ne correspond à votre recherche. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b47da5b412..4facca6c56 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2427,3 +2427,9 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok + Create & Edit Létrehozás és szerkesztés + Review & Compare Áttekintés és összehasonlítás + Forms & Signatures Űrlapok és aláírások + Protect & Standardize Védelem és szabványosítás + Customize & Automate Testreszabás és automatizálás + No tools match your search. Egy eszköz sem felel meg a keresésnek. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9d54a4bead..6420b720e2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2640,3 +2640,9 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + Create & Edit Crea e modifica + Review & Compare Revisione e confronto + Forms & Signatures Moduli e firme + Protect & Standardize Proteggi e standardizza + Customize & Automate Personalizza e automatizza + No tools match your search. Nessuno strumento corrisponde alla ricerca. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 461e074935..0fec03dad2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2428,3 +2428,9 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Create & Edit 作成と編集 + Review & Compare レビューと比較 + Forms & Signatures フォームと署名 + Protect & Standardize 保護と標準化 + Customize & Automate カスタマイズと自動化 + No tools match your search. 検索に一致するツールはありません。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index af140d3a07..4b6ef6795b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2426,3 +2426,9 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Create & Edit Жасау және өңдеу + Review & Compare Шолу және салыстыру + Forms & Signatures Пішіндер және қолтаңбалар + Protect & Standardize Қорғау және стандарттау + Customize & Automate Баптау және автоматтандыру + No tools match your search. Іздеуге сәйкес құралдар жоқ. diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 247b6cff95..9cc5927d6d 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2672,3 +2672,9 @@ key Space Tarpas Make properties default Nustatyti ypatybes kaip numatytąsias Pages selected Pasirinkti puslapiai Page properties Puslapio ypatybės + Create & Edit Kūrimas ir redagavimas + Review & Compare Peržiūra ir palyginimas + Forms & Signatures Formos ir parašai + Protect & Standardize Apsauga ir standartizavimas + Customize & Automate Pritaikymas ir automatizavimas + No tools match your search. Paieškos neatitinka joks įrankis. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 5bb2889c9d..c322e2d7c5 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -811,6 +811,9 @@ mod tests { for command in pdfcraft_engine::commands::COMMANDS { assert!(has(JA(), command.label), "missing command: {}", command.label); } + for category in pdfcraft_engine::catalog::TOOL_CATEGORIES { + assert!(has(JA(), category.label), "missing category: {}", category.label); + } for group in pdfcraft_engine::catalog::TOOL_GROUPS { assert!(has(JA(), group.label), "missing group: {}", group.label); for section in group.sections { diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3dbb247056..c04c83f673 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2408,3 +2408,9 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + Create & Edit Tworzenie i edycja + Review & Compare Recenzja i porównanie + Forms & Signatures Formularze i podpisy + Protect & Standardize Ochrona i standaryzacja + Customize & Automate Dostosowanie i automatyzacja + No tools match your search. Żadne narzędzie nie pasuje do wyszukiwania. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3e55173169..266aa57c9f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2442,3 +2442,9 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + Create & Edit Criar e editar + Review & Compare Revisar e comparar + Forms & Signatures Formulários e assinaturas + Protect & Standardize Proteger e padronizar + Customize & Automate Personalizar e automatizar + No tools match your search. Nenhuma ferramenta corresponde à pesquisa. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1ac9c10ba1..cf6248983a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2341,3 +2341,9 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Create & Edit Создание и редактирование + Review & Compare Рецензирование и сравнение + Forms & Signatures Формы и подписи + Protect & Standardize Защита и стандартизация + Customize & Automate Настройка и автоматизация + No tools match your search. Нет инструментов, соответствующих запросу. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 4165909995..3970769429 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2433,3 +2433,9 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + Create & Edit Ustvarjanje in urejanje + Review & Compare Pregled in primerjava + Forms & Signatures Obrazci in podpisi + Protect & Standardize Zaščita in standardizacija + Customize & Automate Prilagajanje in avtomatizacija + No tools match your search. Iskanju ne ustreza nobeno orodje. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 07b716eac1..b0f5dba9ef 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2341,3 +2341,9 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Create & Edit సృష్టించు & సవరించు + Review & Compare సమీక్షించు & పోల్చు + Forms & Signatures ఫారమ్‌లు & సంతకాలు + Protect & Standardize రక్షించు & ప్రామాణీకరించు + Customize & Automate అనుకూలీకరించు & ఆటోమేట్ చేయి + No tools match your search. మీ శోధనకు సరిపోలే సాధనాలు లేవు. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9fbd4dc966..5842a2f252 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2424,3 +2424,9 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + Create & Edit Oluştur ve Düzenle + Review & Compare İncele ve Karşılaştır + Forms & Signatures Formlar ve İmzalar + Protect & Standardize Koru ve Standartlaştır + Customize & Automate Özelleştir ve Otomatikleştir + No tools match your search. Aramanızla eşleşen araç yok. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 53bc832f55..0e16e392f2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2450,3 +2450,9 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Create & Edit Створення й редагування + Review & Compare Рецензування й порівняння + Forms & Signatures Форми й підписи + Protect & Standardize Захист і стандартизація + Customize & Automate Налаштування й автоматизація + No tools match your search. Немає інструментів, що відповідають пошуку. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a01b47f8a1..76052dc48f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2615,3 +2615,9 @@ key Space Space Redact Image Xóa nội dung nhạy cảm của hình ảnh Create Link… Tạo liên kết… Recognize Text… Nhận dạng văn bản… + Create & Edit Tạo và chỉnh sửa + Review & Compare Xem xét và so sánh + Forms & Signatures Biểu mẫu và chữ ký + Protect & Standardize Bảo vệ và chuẩn hóa + Customize & Automate Tùy chỉnh và tự động hóa + No tools match your search. Không có công cụ nào khớp với tìm kiếm. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3274ff4731..76736ff144 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2392,3 +2392,9 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Create & Edit 创建和编辑 + Review & Compare 审阅和比较 + Forms & Signatures 表单和签名 + Protect & Standardize 保护和标准化 + Customize & Automate 自定义和自动化 + No tools match your search. 没有与搜索匹配的工具。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0ea252e56c..ec1f188f44 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2384,3 +2384,9 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Create & Edit 建立與編輯 + Review & Compare 審閱與比較 + Forms & Signatures 表單與簽名 + Protect & Standardize 保護與標準化 + Customize & Automate 自訂與自動化 + No tools match your search. 沒有符合搜尋的工具。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e0..3f3f6e9913 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,7 @@ macro_rules! tl_ctx { mod a11y_ui; mod actions_ui; +mod all_tools_ui; mod appearance; pub mod canvas; mod chrome; @@ -563,6 +564,9 @@ pub struct PdfCraftApp { pub palette_open: bool, pub palette_query: String, pub all_tools_expanded: bool, + /// The All tools view fills the workspace (its sidebar's expand button). + pub all_tools_full: bool, + pub all_tools_query: String, pub recent: Vec, /// Preferences: reopen the files that were open when PdfCraft last closed (#442). pub reopen_last_session: bool, @@ -929,6 +933,8 @@ impl PdfCraftApp { palette_open: false, palette_query: String::new(), all_tools_expanded: false, + all_tools_full: false, + all_tools_query: String::new(), recent: Vec::new(), reopen_last_session: false, last_session: Default::default(), @@ -2171,6 +2177,10 @@ impl PdfCraftApp { /// that panel. The rail stays in Read mode, and its buttons open panels there too (#932). pub(crate) fn switch_mode(&mut self, mode: Mode, select_tools: bool) { let was_read = self.mode == Mode::Read; + // Another workspace puts the expanded All tools view away. + if mode != Mode::AllTools { + self.all_tools_full = false; + } if select_tools { self.select_mode(mode); } else { @@ -2549,7 +2559,14 @@ impl PdfCraftApp { _ => Some(Dialog::About), } } - ("tools", _) => self.all_tools_expanded = value != "collapsed", + ("tools", _) if value == "full" => { + self.mode = Mode::AllTools; + self.all_tools_full = true; + } + ("tools", _) => { + self.all_tools_full = false; + self.all_tools_expanded = value != "collapsed"; + } ("palette", _) => { self.palette_open = true; self.palette_query = value.to_string(); @@ -3145,6 +3162,8 @@ impl eframe::App for PdfCraftApp { None => canvas::document_area(self, index, ui), }, } + } else if self.mode == Mode::AllTools && self.all_tools_full { + all_tools_ui::show(self, ui); } else { docking::show(self, ui); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 92071c2493..d9ca218e78 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -35,11 +35,13 @@ pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); } +/// Returns whether the leading button (Back, or Expand on All tools) and Close were clicked. fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool, bool) { let mut go_back = false; let mut close = false; ui.horizontal(|ui| { - if back && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() { + let (icon, tip) = if back { ("chevron-left", "Back to all tools") } else { ("maximize-2", "Expand") }; + if icons::button(ui, icon, 26.0, false, tl!(tip)).clicked() { go_back = true; } // Reserve space for Close before laying out the translated title. Long tool names @@ -58,7 +60,11 @@ fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool } fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - let (_, close) = panel_header(ui, t, "All tools", false); + let (expand, close) = panel_header(ui, t, "All tools", false); + if expand { + app.all_tools_full = true; + app.mode = crate::Mode::AllTools; + } if close { app.left_open = false; } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 1b6276d7c4..731ae3a79d 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -125,6 +125,50 @@ fn every_catalog_tool_is_listed_after_view_more() { } } +#[test] +fn expanded_all_tools_lists_every_tool_by_category_and_searches() { + let mut h = harness(|_| {}); + h.get_by_label("Expand").click(); + h.run_steps(3); + assert!(h.state().all_tools_full); + for c in pdfcraft_engine::catalog::TOOL_CATEGORIES { + h.get_by_label(c.label); + } + for g in pdfcraft_engine::catalog::TOOL_GROUPS { + assert!(h.query_all_by_label(g.label).count() >= 1, "tool {} missing from the expanded view", g.label); + } + // An item name finds its tool; the other categories drop out. + h.state_mut().all_tools_query = "rotate".into(); + h.run_steps(3); + assert_eq!(h.query_all_by_label("Organize pages").count(), 1, "only the expanded view's tile; the sidebar is gone"); + assert!(h.query_by_label("Forms & Signatures").is_none()); + h.state_mut().all_tools_query = "no such tool".into(); + h.run_steps(3); + h.get_by_label("No tools match your search."); + // Choosing a tool goes back to the workspace with that tool's panel. + h.state_mut().all_tools_query = "organize".into(); + h.run_steps(3); + h.get_by_label("Organize pages").click(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + assert_eq!(h.state().left, pdfcraft_ui_egui::LeftPanel::Tool("organize")); + h.get_by_label("Back to all tools"); +} + +#[test] +fn collapse_and_escape_close_the_expanded_all_tools() { + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.get_by_label("Collapse").click(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + h.get_by_label("View more"); + h.get_by_label("Expand").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(!h.state().all_tools_full); +} + #[test] fn opening_a_pdf_shows_comments_and_bookmarks() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); From b9e1b06b9456174e4e5aef266f74e5072ef1acaf Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 15 Oct 2026 17:58:11 +0200 Subject: [PATCH 02/43] M3: name the All tools expand and collapse buttons for what they do The sidebar's expand button was labelled "Expand", the same as the Combine grid's per-file Expand on its hover bar. With both on screen, the Combine grid tests' lookup by that label found two buttons and 13 of them failed. The buttons are now "Expand all tools" and "Collapse all tools", translated in all 22 catalogues, and the labels are tl! literals the coverage scan sees. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 ++++++++++++++--------------- crates/ui-egui/src/all_tools_ui.rs | 2 +- crates/ui-egui/src/i18n/ar.tsv | 2 ++ crates/ui-egui/src/i18n/bg.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/kk.tsv | 2 ++ crates/ui-egui/src/i18n/lt.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sl.tsv | 2 ++ crates/ui-egui/src/i18n/te.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/panels.rs | 5 ++-- crates/ui-egui/tests/ui.rs | 6 ++-- 26 files changed, 73 insertions(+), 28 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index db7dc3ebd0..2a094f27ec 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 = "d779dbb265c576d68cd804d0a0505ac5821d74f2d4e5afb663516d8801e0fbbc" +sha256 = "e89a37e6597ce2c9546ba4ab257dbbd0d6509311d061e0da481ad493edfc5fac" # 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 = "69a7a36b9b8fbe6cd04d7f287a77deb01c243cc0f1a438511cf35748dde6b3df" +sha256 = "65ed776368317165b0e77d339522d67d56df15a3446e2e75a88f7f6514fd6c1a" [[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 = "08e35bcca452daaa26efc66a6cbbf75794673cc59ebf42a97ce4180cc6ecfa0c" +sha256 = "5517f7d5c1a5eb75e33229d8b6a4d1014fb36b8dda518c4f9e2a012312198da5" [[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 = "0fd03fafffc2d75bf484186a7dd6ab15ab95d79448e0e798f57519515d73ac4e" +sha256 = "15ee8163cb5ffcc3f337b7015c33c31df14308d0cd2cdd8b515ba2b129bbed68" [[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 = "d3292e186768f75b9cb87097688ff4909c26c9810a7e6542c127aad9fbce5af3" +sha256 = "895a2233d27a77e3debeaf2d12f7552eab0d552eb9fed8bf06cac319bb2e3565" [[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 = "14c9f2ba61ffc925b01e6bf31c7f7761fb5e7adcda1751f6768829341a8e3757" +sha256 = "107b4ba6fdfdb22bb87bf834ea57078a83e73881beaf197160496a0c60bfae59" [[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 = "d2dc6bd5de197eff60a440a05c58c6b7307009ecb08ddf8fbb31ff616633e36e" +sha256 = "b5abd6afc8188025331a75e9dd4bb9aa486e39560620f58bd25caf77a9dc5031" [[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 = "b6ffc5eda71ea13eaea79f5f3368811cb317f0a43e6f6be167bb4742d8e1ae77" +sha256 = "7bd86b52d9944de8496a769d66f279152c2fe52b89dcada393c5a4e9f7cb9020" [[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 = "37b60dda614ebc0e2b141cb27c9b2b7d13b49b347c1a5acb4d395147fbbdd246" +sha256 = "8c18e15224f961b19867076502fac48d5f22a9bd00330ce3a5b18b435e9c06a3" [[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 = "0055dfb04a35abd2c8da01bf3836855074e17bbd2b6332b2d1cecd16fd816938" +sha256 = "bff74bf37446325d9d9fbc99dbfda92e08af1dee63b3781b0d4eabbf687aa7d9" [[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 = "e1c4801f9633f5873f5dc07fa6de01375369b636282fa22e8cd223adb9737b42" +sha256 = "4423f887698713d1ed2880e8e38dc9fa8022d2b4906f88a038c17bfe79072cee" [[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 = "36bd183e5bb36e76e2eb40ac68d06f2d0dcd962b42998917ba3ad5e7d8ca2b19" +sha256 = "7641856dbc25181ff3290d8253b2abba3bd903b11d37b0537bb07cba0d0d18dc" [[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 = "c10dab015d7cabc571dd2a462a0ca22a1b1461b9b747c4998e7a7e4e1f9b6b71" +sha256 = "69d3b6ab0e3435e0a142ab84c8c9348ce33395d5d68af121481e631947361873" [[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 = "7b1db4f5f3ce50c8b995037d84cce86088e3af17942b2e1d0197234a2454ecdd" +sha256 = "8f404ffaaec75c4e024cfa3ec41c1d94cb14291fb8937ce79e0b5d214423505a" [[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 = "3ef5f6e5c28c8060017b1219d0ae3fe6308eaa4f69baf821816da1ac2b4468cd" +sha256 = "209b739e765adb88c1e14517cb782e9622ef9fa6f748ba17c7f8e04957009501" [[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 = "d2b91bcf778610861b515865339a487f279f68188617fd2729e0d8d8455d7a2b" +sha256 = "450647a98ee292e84f281fd1aa09170e3747fbdac0259dcc75e173d0e8af88aa" [[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 = "b3a0c1563ba9c67ffbebf805769bae9c5a809cd14fd37a0441a30e19cacb28c8" +sha256 = "24827470a54676a9819dc8c5ea56aba830b808586d0cda22b368be4747126022" [[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 = "15dde8ef0f0d20a6e99b82c7f5297b41130e9f0d8f11494fcdc09c69bcbfb43c" +sha256 = "4d5d708e54b5e1534b4d11aa30ac3aebbb591993fb6135beb05015fd9ed1ec2b" [[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 = "9d879f455313d528d2d8c91e080a1fa199f50d901c7691f7b6dc3f15be73fd1a" +sha256 = "8c074e0fc0dc7c6da1ffea2a87b8ebbc2abb16915e3d3ebd137b775e6267890f" [[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 = "e9ecf10658e7c02e2c59e3fb354e561ade437ce9938cb08ebdc9488184efb59e" +sha256 = "476ec3ac3ba98e5fe243a2e66379d58cd95a0b1d6488730346d934af0212ff10" [[asset]] @@ -240,7 +240,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "29677be4697aa6feeb1d4251795960e525a3a47ce3381a32b0157f488542b30e" +sha256 = "9f417e74d6590fbf68e63ad1bed8f808c92900548633c8dbe63b0f7d70f271b7" [[asset]] path = "assets/icons/accessibility.svg" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bb3a8708b1a2f72782ab41b0ae43e902f089caffd545004f5d1b2112d20df78c" +sha256 = "0e6d29aadf9d09f0acba0694a3fbe3bb29ee8e52c1027adc02cc9f6aa47b6358" [[asset]] path = "vendor/vello_common/assets/probe.png" diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs index e46ec6c723..70be7747c4 100644 --- a/crates/ui-egui/src/all_tools_ui.rs +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -37,7 +37,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("All tools")).font(theme::semibold(22.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "minimize-2", 28.0, false, tl!("Collapse")).clicked() { + if icons::button(ui, "minimize-2", 28.0, false, tl!("Collapse all tools")).clicked() { app.all_tools_full = false; } }); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1915ee5fc6..0e847fd4c3 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2712,3 +2712,5 @@ certificate Version الإصدار Protect & Standardize الحماية والتوحيد القياسي Customize & Automate التخصيص والأتمتة No tools match your search. لا توجد أدوات تطابق بحثك. + Expand all tools توسيع كل الأدوات + Collapse all tools طي كل الأدوات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 57bbffaabb..5b93bd1425 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2397,3 +2397,5 @@ animation effects Off Изключени Protect & Standardize Защита и стандартизация Customize & Automate Персонализиране и автоматизация No tools match your search. Няма инструменти, отговарящи на търсенето. + Expand all tools Разгъване на всички инструменти + Collapse all tools Свиване на всички инструменти diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c52ec8b20d..97f8017326 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2508,3 +2508,5 @@ animation effects Off Vypnuto Protect & Standardize Ochrana a standardizace Customize & Automate Přizpůsobení a automatizace No tools match your search. Hledání neodpovídá žádný nástroj. + Expand all tools Rozbalit všechny nástroje + Collapse all tools Sbalit všechny nástroje diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 94863e0743..87b8cefad8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2435,3 +2435,5 @@ animation effects Off Aus Protect & Standardize Schützen & Standardisieren Customize & Automate Anpassen & Automatisieren No tools match your search. Keine Werkzeuge entsprechen Ihrer Suche. + Expand all tools Alle Werkzeuge ausklappen + Collapse all tools Alle Werkzeuge einklappen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b22b3cb2a6..7df73c841c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2444,3 +2444,5 @@ animation effects Off Ανενεργά Protect & Standardize Προστασία και τυποποίηση Customize & Automate Προσαρμογή και αυτοματοποίηση No tools match your search. Κανένα εργαλείο δεν ταιριάζει με την αναζήτησή σας. + Expand all tools Ανάπτυξη όλων των εργαλείων + Collapse all tools Σύμπτυξη όλων των εργαλείων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 332cd43f06..f14153bb8a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2574,3 +2574,5 @@ animation effects Off Desactivados Protect & Standardize Proteger y estandarizar Customize & Automate Personalizar y automatizar No tools match your search. Ninguna herramienta coincide con la búsqueda. + Expand all tools Expandir todas las herramientas + Collapse all tools Contraer todas las herramientas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0e69171c6f..01531f0b34 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2434,3 +2434,5 @@ animation effects Off Désactivés Protect & Standardize Protéger et normaliser Customize & Automate Personnaliser et automatiser No tools match your search. Aucun outil ne correspond à votre recherche. + Expand all tools Développer tous les outils + Collapse all tools Réduire tous les outils diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4facca6c56..ef4b84257f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2433,3 +2433,5 @@ animation effects Off Ki Protect & Standardize Védelem és szabványosítás Customize & Automate Testreszabás és automatizálás No tools match your search. Egy eszköz sem felel meg a keresésnek. + Expand all tools Az összes eszköz kibontása + Collapse all tools Az összes eszköz összecsukása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6420b720e2..2d9b25baab 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2646,3 +2646,5 @@ certificate SHA-256 digest Impronta SHA-256 Protect & Standardize Proteggi e standardizza Customize & Automate Personalizza e automatizza No tools match your search. Nessuno strumento corrisponde alla ricerca. + Expand all tools Espandi tutti gli strumenti + Collapse all tools Comprimi tutti gli strumenti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0fec03dad2..7c5ea80bcf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2434,3 +2434,5 @@ animation effects Off オフ Protect & Standardize 保護と標準化 Customize & Automate カスタマイズと自動化 No tools match your search. 検索に一致するツールはありません。 + Expand all tools すべてのツールを展開 + Collapse all tools すべてのツールを折りたたむ diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 4b6ef6795b..a8f4d51af6 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2432,3 +2432,5 @@ animation effects Off Өшірулі Protect & Standardize Қорғау және стандарттау Customize & Automate Баптау және автоматтандыру No tools match your search. Іздеуге сәйкес құралдар жоқ. + Expand all tools Барлық құралдарды жаю + Collapse all tools Барлық құралдарды жию diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2315154728..4d5afa71ed 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2678,6 +2678,8 @@ key Space Tarpas Protect & Standardize Apsauga ir standartizavimas Customize & Automate Pritaikymas ir automatizavimas No tools match your search. Paieškos neatitinka joks įrankis. + Expand all tools Išskleisti visus įrankius + Collapse all tools Suskleisti visus įrankius # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c04c83f673..9a2c5cf53d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2414,3 +2414,5 @@ animation effects Off Wyłączone Protect & Standardize Ochrona i standaryzacja Customize & Automate Dostosowanie i automatyzacja No tools match your search. Żadne narzędzie nie pasuje do wyszukiwania. + Expand all tools Rozwiń wszystkie narzędzia + Collapse all tools Zwiń wszystkie narzędzia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 266aa57c9f..6a09d38e46 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2448,3 +2448,5 @@ animation effects Off Desativados Protect & Standardize Proteger e padronizar Customize & Automate Personalizar e automatizar No tools match your search. Nenhuma ferramenta corresponde à pesquisa. + Expand all tools Expandir todas as ferramentas + Collapse all tools Recolher todas as ferramentas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cf6248983a..de69cb2848 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2347,3 +2347,5 @@ animation effects Off Выкл Protect & Standardize Защита и стандартизация Customize & Automate Настройка и автоматизация No tools match your search. Нет инструментов, соответствующих запросу. + Expand all tools Развернуть все инструменты + Collapse all tools Свернуть все инструменты diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3970769429..bbc008db7d 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2439,3 +2439,5 @@ animation effects Off Izklopljeni Protect & Standardize Zaščita in standardizacija Customize & Automate Prilagajanje in avtomatizacija No tools match your search. Iskanju ne ustreza nobeno orodje. + Expand all tools Razširi vsa orodja + Collapse all tools Strni vsa orodja diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index b0f5dba9ef..61352bcea9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2347,3 +2347,5 @@ animation effects Off ఆఫ్ Protect & Standardize రక్షించు & ప్రామాణీకరించు Customize & Automate అనుకూలీకరించు & ఆటోమేట్ చేయి No tools match your search. మీ శోధనకు సరిపోలే సాధనాలు లేవు. + Expand all tools అన్ని సాధనాలను విస్తరించు + Collapse all tools అన్ని సాధనాలను కుదించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5842a2f252..355a96e647 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2430,3 +2430,5 @@ animation effects Off Kapalı Protect & Standardize Koru ve Standartlaştır Customize & Automate Özelleştir ve Otomatikleştir No tools match your search. Aramanızla eşleşen araç yok. + Expand all tools Tüm araçları genişlet + Collapse all tools Tüm araçları daralt diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0e16e392f2..b2b3aa6ab0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2456,3 +2456,5 @@ animation effects Off Вимкнено Protect & Standardize Захист і стандартизація Customize & Automate Налаштування й автоматизація No tools match your search. Немає інструментів, що відповідають пошуку. + Expand all tools Розгорнути всі інструменти + Collapse all tools Згорнути всі інструменти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 43aaf035c2..78c53101ac 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2621,6 +2621,8 @@ key Space Space Protect & Standardize Bảo vệ và chuẩn hóa Customize & Automate Tùy chỉnh và tự động hóa No tools match your search. Không có công cụ nào khớp với tìm kiếm. + Expand all tools Mở rộng tất cả công cụ + Collapse all tools Thu gọn tất cả công cụ # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 76736ff144..2af48b2786 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2398,3 +2398,5 @@ animation effects Off 关 Protect & Standardize 保护和标准化 Customize & Automate 自定义和自动化 No tools match your search. 没有与搜索匹配的工具。 + Expand all tools 展开所有工具 + Collapse all tools 折叠所有工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5d5f038b81..e783a83be1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2388,3 +2388,5 @@ animation effects Off 關 Protect & Standardize 保護與標準化 Customize & Automate 自訂與自動化 No tools match your search. 沒有符合搜尋的工具。 + Expand all tools 展開所有工具 + Collapse all tools 摺疊所有工具 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d9ca218e78..7060610d51 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -40,8 +40,9 @@ fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool let mut go_back = false; let mut close = false; ui.horizontal(|ui| { - let (icon, tip) = if back { ("chevron-left", "Back to all tools") } else { ("maximize-2", "Expand") }; - if icons::button(ui, icon, 26.0, false, tl!(tip)).clicked() { + // Labels distinct from the Combine grid's Expand and Collapse, which can show beside them. + let (icon, tip) = if back { ("chevron-left", tl!("Back to all tools")) } else { ("maximize-2", tl!("Expand all tools")) }; + if icons::button(ui, icon, 26.0, false, tip).clicked() { go_back = true; } // Reserve space for Close before laying out the translated title. Long tool names diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 731ae3a79d..a8ff0ed03c 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -128,7 +128,7 @@ fn every_catalog_tool_is_listed_after_view_more() { #[test] fn expanded_all_tools_lists_every_tool_by_category_and_searches() { let mut h = harness(|_| {}); - h.get_by_label("Expand").click(); + h.get_by_label("Expand all tools").click(); h.run_steps(3); assert!(h.state().all_tools_full); for c in pdfcraft_engine::catalog::TOOL_CATEGORIES { @@ -158,11 +158,11 @@ fn expanded_all_tools_lists_every_tool_by_category_and_searches() { #[test] fn collapse_and_escape_close_the_expanded_all_tools() { let mut h = harness(|app| app.set_option("tools", "full").unwrap()); - h.get_by_label("Collapse").click(); + h.get_by_label("Collapse all tools").click(); h.run_steps(3); assert!(!h.state().all_tools_full); h.get_by_label("View more"); - h.get_by_label("Expand").click(); + h.get_by_label("Expand all tools").click(); h.run_steps(3); h.key_press(egui::Key::Escape); h.run_steps(3); From b349ef480ff541352af308b0eef5bd1969512d0a Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 15 Oct 2026 15:33:32 +0200 Subject: [PATCH 03/43] fix(ui): Document Properties values line up and long fields wrap The Description tab's plain values (File, Application, PDF producer) now start where the text inside the Title/Author fields does, the fields use the dialog's full width instead of a fixed 420 points, and Subject and Keywords wrap onto more lines instead of clipping mid-word. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 16 ++++++++---- crates/ui-egui/src/widgets.rs | 2 ++ crates/ui-egui/tests/dialog_chrome.rs | 37 +++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629c..7e6966d848 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -118,11 +118,14 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some((_, id)) = app.active_ids() else { return }; let Some(doc) = app.session.get(id) else { return }; let i = &doc.info; + // Description shows plain values among text fields; they start where the fields' text does. + let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); + let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; let row = |ui: &mut egui::Ui, k: &str, v: String| { // Row labels are UI chrome; values are document data and stay as they are. ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.scope(|ui| { - ui.set_max_width(420.0); + ui.horizontal(|ui| { + ui.add_space(inset); ui.add( egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(), ); @@ -134,11 +137,14 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { PropsTab::Description => { row(ui, "File", crate::bidi::visual(&doc.name).into_owned()); match app.props_draft.as_mut() { - Some((_, draft)) if doc.allows_modification() => { + Some((_, draft)) if editable_info => { for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.add(widgets::line(v).desired_width(420.0).background_color(t.field).id_salt(("info", *k))) - .labelled_by(l.id); + // Subject and Keywords are often a sentence or a list: they wrap and grow + // instead of hiding most of the value past the field's edge. + let field = + if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; + ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); ui.end_row(); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b6..852a28b953 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -7,6 +7,8 @@ use crate::{PdfCraftApp, icons}; /// Horizontal and vertical inset of a single-line field. const FIELD_MARGIN: egui::Margin = egui::Margin::symmetric(10, 6); +/// Where text starts inside a [`line`] or [`block`] field, so plain values beside fields can line up with it. +pub(crate) const FIELD_TEXT_INSET: f32 = FIELD_MARGIN.left as f32; /// Outer height of a single-line field, including its inset. const FIELD_MIN_HEIGHT: f32 = 30.0; /// Padding inside every modal. diff --git a/crates/ui-egui/tests/dialog_chrome.rs b/crates/ui-egui/tests/dialog_chrome.rs index 57f549305d..1cbc534a7f 100644 --- a/crates/ui-egui/tests/dialog_chrome.rs +++ b/crates/ui-egui/tests/dialog_chrome.rs @@ -6,11 +6,17 @@ use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::PdfCraftApp; fn fixture() -> Vec { - let objs = [ + fixture_with_info(None) +} + +/// A one-page file, with `info` as its document information dictionary when given. +fn fixture_with_info(info: Option<&str>) -> Vec { + let mut objs = vec![ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", ]; + objs.extend(info); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -22,7 +28,8 @@ fn fixture() -> Vec { for o in offsets { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let info = if info.is_some() { " /Info 4 0 R" } else { "" }; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{info} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); out } @@ -96,3 +103,29 @@ fn document_properties_long_filename_does_not_overflow_card() { let card = card(&h, "dialog"); assert!(card.width() <= 700.0, "dialog card should not blow out horizontally on long filename, width was: {}", card.width()); } + +#[test] +fn document_properties_values_line_up_and_long_fields_wrap() { + let info = "<< /Title (Showcase) /Subject (A specimen document exercising typography, world scripts, vector art, forms and annotations) /Creator (demo-pdf) >>"; + 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("doc.pdf", None, fixture_with_info(Some(info))).unwrap(); + app.set_option("dialog", "properties").unwrap(); + app + }); + h.run_steps(4); + let card = card(&h, "dialog"); + let title = h.get_by_role_and_label(Role::TextInput, "Title").rect(); + // The fields reach the card's padding instead of stopping short of it. + assert!(card.right() - title.right() <= 24.0, "title field {title:?} in {card:?}"); + // Plain values start where the fields' text does. + for value in ["doc.pdf", "demo-pdf"] { + // The file name is also the document tab's label. + let v = h.query_all_by_label(value).map(|n| n.rect()).find(|r| card.contains_rect(*r)).unwrap_or_else(|| panic!("{value} in the dialog")); + assert!((v.left() - (title.left() + 10.0)).abs() <= 1.0, "{value} at {v:?}, title field {title:?}"); + } + // A long subject wraps onto a second line rather than running past the field's edge. + let subject = h.get_by_role_and_label(Role::MultilineTextInput, "Subject").rect(); + assert!(subject.height() > title.height() + 8.0, "subject field {subject:?}, title field {title:?}"); +} From d48ce4ae1bace6c3cae4525b4f3f727c311e5531 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 15 Oct 2026 15:44:40 +0200 Subject: [PATCH 04/43] fix(ui): Document Properties gains a Space Audit tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properties ▸ Space Audit shows the same bytes-by-kind table as the PDF Optimizer's Audit Space Usage, which now shares its table code. The audit walks the whole file, so it runs when the tab opens and again only after an edit (keyed on the document and its edit generation). The tab is also reachable as `--dialog space-audit`. The audit table no longer overflows its dialog: egui's default minimum column width stretched the narrow Bytes column past the computed widths, widening the card by about 5 points. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 16 +++++++++-- crates/ui-egui/src/lib.rs | 5 ++++ crates/ui-egui/src/optimize_ui.rs | 38 ++++++++++++++++++++++----- crates/ui-egui/tests/dialog_chrome.rs | 38 +++++++++++++++++++++++++++ parity/acrobat-features.toml | 6 ++--- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7e6966d848..bf21cd38c7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -34,6 +34,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) { app.session.refresh_fonts(id); } + // Properties ▸ Space Audit walks the whole file, so it is measured once per edit, not per frame. + if dialog == Dialog::Properties(PropsTab::SpaceAudit) { + app.refresh_space_audit(); + } // Seed the editable Description fields from the document when the dialog opens. if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids()) && app.props_draft.as_ref().is_none_or(|(d, _)| *d != id) @@ -107,6 +111,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (PropsTab::Security, tl!("Security")), (PropsTab::Fonts, tl!("Fonts")), (PropsTab::Advanced, tl!("Advanced")), + (PropsTab::SpaceAudit, tl!("Space Audit")), ] { if widgets::mode_tab(ui, label, tab == tb).clicked() { next = Dialog::Properties(tb); @@ -133,6 +138,11 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.end_row(); }; egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { + if tab == PropsTab::SpaceAudit { + ui.add_space(4.0); + crate::optimize_ui::audit_table(ui, &app.space_audit, &t); + return; + } egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { PropsTab::Description => { row(ui, "File", crate::bidi::visual(&doc.name).into_owned()); @@ -409,7 +419,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.end_row(); } } - }) + // Laid out above, outside the two-column grid. + PropsTab::SpaceAudit => {} + }); }); } Dialog::Split => { @@ -727,7 +739,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { optimize_now = ok; close = ok || cancel; if std::mem::take(&mut app.optimize_draft.audit) { - app.space_audit = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.audit_space()).unwrap_or_default(); + app.refresh_space_audit(); next = Dialog::AuditSpace; } return; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e0..dd045e105d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -360,6 +360,7 @@ pub enum PropsTab { Security, Fonts, Advanced, + SpaceAudit, } /// Duplicate Field: the field and the pages (all, or a range, 1-based). @@ -682,6 +683,8 @@ pub struct PdfCraftApp { pub cert_viewer: Option, /// The last space audit. pub space_audit: Vec, + /// The document and edit generation `space_audit` was measured from. + pub(crate) space_audit_of: Option<(DocId, u64)>, /// Combine files: the files staged so far. pub combine_draft: Vec, /// The combine list's field-name conflict preview (computed once per list, never saved). @@ -1001,6 +1004,7 @@ impl PdfCraftApp { os_key_store_ids: false, cert_viewer: None, space_audit: Vec::new(), + space_audit_of: None, combine_draft: Vec::new(), combine_conflicts: Default::default(), combine_tab: Default::default(), @@ -2513,6 +2517,7 @@ impl PdfCraftApp { "security" => Some(Dialog::Properties(PropsTab::Security)), "fonts" => Some(Dialog::Properties(PropsTab::Fonts)), "advanced" => Some(Dialog::Properties(PropsTab::Advanced)), + "space-audit" => Some(Dialog::Properties(PropsTab::SpaceAudit)), "shortcuts" => Some(Dialog::Shortcuts), "split" => Some(Dialog::Split), "protect" => Some(Dialog::Protect), diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184a..5fc19606dc 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; -use egui::{Align, Layout}; +use egui::{Align, Layout, RichText}; use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings}; use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; @@ -246,9 +246,19 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo /// Audit Space Usage: how the file's bytes are spent now, by kind of content (not what /// optimizing would save). pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) -> bool { - use egui::{RichText, TextWrapMode, WidgetText}; ui.label(RichText::new(tl!("Space Audit")).font(crate::theme::semibold(18.0))); ui.add_space(4.0); + audit_table(ui, rows, t); + ui.add_space(12.0); + let mut ok = false; + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked())); + ok +} + +/// The audit's explanation and its table: bytes and share of the file per kind of content. +/// Shared by the Optimizer's Audit Space Usage and Properties ▸ Space Audit. +pub(crate) fn audit_table(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) { + use egui::{TextWrapMode, WidgetText}; ui.label(RichText::new(tl!("What takes up the space in this file, by kind of content.")).color(t.text_muted)); ui.add_space(8.0); // Every cell is laid out first so each column can be exactly as wide as its widest cell. A @@ -279,7 +289,9 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S let numbers = width(1) + width(2) + 2.0 * gap; let widths = [width(0).max(ui.available_width() - numbers), width(1), width(2)]; let height = table.iter().flatten().map(|g| g.size().y).fold(ui.spacing().interact_size.y, f32::max); - egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).show(ui, |ui| { + // No minimum column width: egui's default widened a narrow Bytes column past `widths` and + // pushed the table beyond the dialog's padding. + egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).min_col_width(0.0).show(ui, |ui| { for row in table { for (column, (cell, width)) in row.into_iter().zip(widths).enumerate() { // Numbers line up on their last digit. @@ -290,13 +302,25 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S ui.end_row(); } }); - ui.add_space(12.0); - let mut ok = false; - ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked())); - ok } impl PdfCraftApp { + /// Measure the active document's space use, unless `space_audit` already describes it as + /// it is now. With no document the audit is empty. + pub(crate) fn refresh_space_audit(&mut self) { + let Some((_, id)) = self.active_ids() else { + self.space_audit.clear(); + self.space_audit_of = None; + return; + }; + let Some(doc) = self.session.get(id) else { return }; + let key = (id, doc.edit_generation()); + if self.space_audit_of != Some(key) { + self.space_audit = doc.audit_space(); + self.space_audit_of = Some(key); + } + } + /// Optimize PDF with the dialog's choices on a worker thread; the copy is saved when done. pub fn optimize_with_draft(&mut self) { let (settings, discard) = (self.optimize_draft.settings.clone(), self.optimize_draft.discard.clone()); diff --git a/crates/ui-egui/tests/dialog_chrome.rs b/crates/ui-egui/tests/dialog_chrome.rs index 1cbc534a7f..e21363535a 100644 --- a/crates/ui-egui/tests/dialog_chrome.rs +++ b/crates/ui-egui/tests/dialog_chrome.rs @@ -129,3 +129,41 @@ fn document_properties_values_line_up_and_long_fields_wrap() { let subject = h.get_by_role_and_label(Role::MultilineTextInput, "Subject").rect(); assert!(subject.height() > title.height() + 8.0, "subject field {subject:?}, title field {title:?}"); } + +#[test] +fn space_audit_tab_measures_the_file_and_stays_in_the_card() { + use pdfcraft_ui_egui::{Dialog, PropsTab}; + let open = |dialog: Dialog| { + 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("doc.pdf", None, fixture()).unwrap(); + app.dialog = Some(dialog); + app + }); + h.run_steps(4); + h + }; + let fonts = card(&open(Dialog::Properties(PropsTab::Fonts)), "dialog"); + // The tab is reachable by name, like the other Properties tabs. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app.set_option("dialog", "space-audit").unwrap(); + app + }); + h.run_steps(4); + assert_eq!(h.state().dialog, Some(Dialog::Properties(PropsTab::SpaceAudit))); + assert!(!h.state().space_audit.is_empty(), "the tab measures the file when it opens"); + h.get_by_label("Total"); + // Switching tabs keeps the dialog the same size, and the table stays inside its padding, + // in the tab and in the Optimizer's own Audit Space Usage dialog. + let tab = card(&h, "dialog"); + assert!((tab.width() - fonts.width()).abs() < 0.5, "space audit card {tab:?}, fonts card {fonts:?}"); + for h in [h, open(Dialog::AuditSpace)] { + let card = card(&h, "dialog"); + let pct = h.get_by_label("Percentage").rect(); + assert!(pct.right() <= card.right() - 16.0, "Percentage {pct:?} in {card:?}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..ac182f822b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3436,8 +3436,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/editing.rs::cancelling_properties_discards_the_draft"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/editing.rs::cancelling_properties_discards_the_draft", "crates/ui-egui/tests/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"] +notes = "Description, Initial View, Security, Fonts and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. No Custom tab (organize.custom-properties)" [[feature]] id = "organize.edit-metadata" @@ -7597,7 +7597,7 @@ status = "shipped" commands = ["optimize.advanced"] tools = ["doc_audit_space"] evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/viewer.rs::space_audit_numbers_line_up_under_their_headers"] -notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)" +notes = "PDF Optimizer ▸ Audit space usage, and Document Properties ▸ Space Audit: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)" [[feature]] id = "optimize.optimizer-presets" From fb10540990183095cd8e9d218c30271f37b6c0c9 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 15 Oct 2026 15:58:28 +0200 Subject: [PATCH 05/43] fix(ui): dialog buttons hug their content; no page cursors under modals The shared dialog footer laid its buttons out right-to-left in all the height left, centred. After a taller Document Properties tab the modal kept that tab's height, so Close floated far below a shorter tab's last row. The footer is now one row high. Page hovers (form fields, comments, the link tool, image editing) set the cursor from the raw pointer position, so a hand or text cursor showed over fields under a dialog's backdrop, where nothing takes clicks. While a modal is open the cursor starts from the arrow each frame and only the modal's own widgets change it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 80 +++++++++++++++------------ crates/ui-egui/tests/dialog_chrome.rs | 51 +++++++++++++++++ 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf21cd38c7..bfb8d9aa72 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,12 @@ use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, wid const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { + // The page sets hover cursors from the raw pointer position (form fields, links, comments) + // before any dialog is drawn, and nothing under a modal's backdrop takes clicks. Start from + // the arrow; the modal's own widgets set theirs as they are drawn. + if app.modal_open() || ctx.memory(|m| m.top_modal_layer().is_some()) { + ctx.set_cursor_icon(egui::CursorIcon::Default); + } dialogs(app, ctx); // Last, so it sits above Document Properties when Security's Unlock… asked for it. unlock(app, ctx); @@ -1201,43 +1207,47 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } ui.add_space(16.0); let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if dialog == Dialog::Recovery { - if widgets::pill_button(ui, tl!("Recover"), true).clicked() { - recover = Some(true); + // One row high: on its own, a right-to-left layout centres the buttons in all the height + // left, which after a taller Properties tab is the room that tab left behind. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if dialog == Dialog::Recovery { + if widgets::pill_button(ui, tl!("Recover"), true).clicked() { + recover = Some(true); + close = true; + } + if widgets::pill_button(ui, tl!("Discard"), false).clicked() { + recover = Some(false); + close = true; + } + } else if dialog == Dialog::NumberPages { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + apply_number = true; + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + } else if dialog == Dialog::Split { + if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { + split_now = split_ready.clone(); + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + } else if changed { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + apply = true; + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { close = true; } - if widgets::pill_button(ui, tl!("Discard"), false).clicked() { - recover = Some(false); - close = true; - } - } else if dialog == Dialog::NumberPages { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply_number = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if dialog == Dialog::Split { - if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { - split_now = split_ready.clone(); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if changed { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { - close = true; - } + }) }); }); if let Some(yes) = recover { diff --git a/crates/ui-egui/tests/dialog_chrome.rs b/crates/ui-egui/tests/dialog_chrome.rs index e21363535a..ce8c9d5a6f 100644 --- a/crates/ui-egui/tests/dialog_chrome.rs +++ b/crates/ui-egui/tests/dialog_chrome.rs @@ -167,3 +167,54 @@ fn space_audit_tab_measures_the_file_and_stays_in_the_card() { assert!(pct.right() <= card.right() - 16.0, "Percentage {pct:?} in {card:?}"); } } + +#[test] +fn dialog_buttons_follow_the_content_after_a_taller_tab() { + use pdfcraft_ui_egui::{Dialog, PropsTab}; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app.dialog = Some(Dialog::Properties(PropsTab::InitialView)); + app + }); + h.run_steps(4); + h.state_mut().dialog = Some(Dialog::Properties(PropsTab::Description)); + h.run_steps(4); + // The card shrinks back to the shorter tab: Close sits under the last row, not halfway + // down the room the taller tab left behind. + let last = h.get_by_label("PDF producer").rect(); + let close = h.get_by_role_and_label(Role::Button, "Close").rect(); + assert!(close.top() - last.bottom() <= 40.0, "Close {close:?} under the last row {last:?}"); + let card = card(&h, "dialog"); + assert!(card.bottom() - close.bottom() <= 24.0, "Close {close:?} at the foot of {card:?}"); +} + +#[test] +fn page_hover_cursors_stay_off_while_a_dialog_is_open() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + // Small enough that the check box sits above the dialog card. + app.set_option("zoom", "100").unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + // The middle of the "agree" check box (/Rect [20 310 36 326] on a 300 × 400 page). + let page = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = page.width() / 300.0; + let check = page.min + egui::vec2(28.0 * k, (400.0 - 318.0) * k); + h.hover_at(check); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the check box takes clicks"); + h.state_mut().set_option("dialog", "security").unwrap(); + h.run_steps(4); + let card = card(&h, "dialog"); + assert!(!card.contains(check), "the check box {check:?} is beside the dialog {card:?}, under its backdrop"); + h.hover_at(check + egui::vec2(1.0, 0.0)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "nothing under the dialog's backdrop can be clicked"); +} From 23ed4cca657327e849f3bc5e571df5dcd3f5e2f8 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 18 Oct 2026 16:33:12 +0200 Subject: [PATCH 06/43] M3: All tools view keeps Escape for dialogs, leaves with the mode option, and searches Arabic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for the expanded All tools view: - Escape closed the view even when a dialog or prompt over it owned the key. It now checks modal_open() first. - set_option("mode", …) changed workspace without clearing the view, so leaving and coming back to All tools reopened it. Another workspace now puts it away, as switch_mode does. - Right-to-left translations are stored in display order, so a multi-word Arabic query typed in logical order missed its tool. Such a query now matches word by word, as the command palette's search does. Each test was seen to fail with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/all_tools_ui.rs | 27 +++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/tests/ui.rs | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs index 70be7747c4..e9e0f6128a 100644 --- a/crates/ui-egui/src/all_tools_ui.rs +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -12,11 +12,23 @@ const TILE: egui::Vec2 = vec2(148.0, 118.0); /// Item names a tile lists when the search found the tool by them. const MATCHES_SHOWN: usize = 2; +/// Whether `query` (already trimmed and lower-cased) is in `label` or its translation. +/// A right-to-left language's translations are in display order (their words run the other way) +/// while the query is in typing order, so there several words match one by one, as in the +/// command palette. +fn label_hit(label: &str, translated: &str, query: &str, rtl: bool) -> bool { + let translated = translated.to_lowercase(); + label.to_lowercase().contains(query) + || translated.contains(query) + || (rtl && query.contains(char::is_whitespace) && query.split_whitespace().all(|word| translated.contains(word))) +} + /// Whether `group` matches `query` (already trimmed and lower-cased), in English or the UI /// language. `None` is no match; otherwise the items that matched, empty when the tool's own /// name did. fn matches(query: &str, group: &ToolGroup) -> Option> { - let hit = |label: &str| label.to_lowercase().contains(query) || tl!(label).to_lowercase().contains(query); + let rtl = crate::i18n::current().rtl(); + let hit = |label: &str| label_hit(label, tl!(label), query, rtl); if query.is_empty() || hit(group.label) { return Some(Vec::new()); } @@ -26,7 +38,8 @@ fn matches(query: &str, group: &ToolGroup) -> Option> { pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // A dialog or prompt over the view owns Escape. + if !app.modal_open() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.all_tools_full = false; return; } @@ -155,4 +168,14 @@ mod tests { assert!(!rotate.is_empty() && rotate.iter().all(|l| l.to_lowercase().contains("rotate")), "{rotate:?}"); assert_eq!(matches("no such tool anywhere", group("organize")), None); } + + #[test] + fn right_to_left_labels_match_a_query_typed_in_logical_order() { + let shown = crate::bidi::display_rtl("تنظيم الصفحات"); + assert_ne!(shown, "تنظيم الصفحات", "the catalogue reverses the words for display"); + assert!(label_hit("Organize pages", &shown, "تنظيم الصفحات", true)); + assert!(label_hit("Organize pages", &shown, "الصفحات", true), "one word"); + assert!(!label_hit("Organize pages", &shown, "تنظيم الملفات", true), "every word must be there"); + assert!(!label_hit("Organize pages", &shown, "تنظيم الصفحات", false), "left-to-right labels stay exact"); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 63c5a37d58..b6275cce2e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2515,6 +2515,10 @@ impl PdfCraftApp { let mode = Mode::parse(value).unwrap_or(Mode::AllTools); self.mode_override = Some(mode); self.mode = mode; + // As switch_mode: another workspace puts the expanded All tools view away. + if mode != Mode::AllTools { + self.all_tools_full = false; + } } ("default-mode", _) => { self.default_mode = Mode::parse(value).ok_or("default-mode must be all, read, edit, convert or sign")?; diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 8f8916749b..6c51eae18f 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -169,6 +169,27 @@ fn collapse_and_escape_close_the_expanded_all_tools() { assert!(!h.state().all_tools_full); } +#[test] +fn escape_in_a_dialog_over_the_expanded_all_tools_leaves_the_view_open() { + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().all_tools_full, "Escape belonged to the dialog"); +} + +#[test] +fn another_workspace_from_the_mode_option_puts_the_expanded_all_tools_away() { + let mut app = PdfCraftApp::new(); + app.set_option("tools", "full").unwrap(); + app.set_option("mode", "all").unwrap(); + assert!(app.all_tools_full, "All tools itself keeps the view"); + app.set_option("mode", "read").unwrap(); + app.set_option("mode", "all").unwrap(); + assert!(!app.all_tools_full, "coming back shows the sidebar, as the mode bar does"); +} + #[test] fn opening_a_pdf_shows_comments_and_bookmarks() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); From cb21b2cce4c4f848b92b9427c67ac99f96c633d6 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 16:47:14 +0200 Subject: [PATCH 07/43] M3: opening a file and expanding All tools take the usual workspace path Review follow-ups for the expanded All tools view: - Opening a document left the view on, either over the new document (All tools as the default workspace) or waiting to reappear on the next switch back (Read as the default, or a mode override). Activating a document now puts it away, and the reset lives in enter_mode, which every workspace change goes through. - The sidebar's expand button and `--tools full` assigned the mode directly. From Read the right panel saved on entry was not restored, and from Edit the Edit text tool stayed active. Both now go through switch_mode, as the mode bar does, so the sidebar shows the All tools list again on collapse. Each new test was seen to fail with the fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 26 ++++++++++++++------- crates/ui-egui/src/panels.rs | 3 +-- crates/ui-egui/tests/ui.rs | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 81184ce686..0cd9a29344 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1426,6 +1426,10 @@ impl PdfCraftApp { { view.open_organize(); } + // The document shows, not the expanded All tools view over it (whichever workspace opens). + if activate { + self.all_tools_full = false; + } if activate && let Some(mode) = self.mode_override { // Explicit mode options do not reset independent --tool / --left choices. self.mode = mode; @@ -2286,6 +2290,10 @@ impl PdfCraftApp { /// again and applies a paragraph still open in the editor. fn enter_mode(&mut self, mode: Mode) { self.mode = mode; + // Another workspace puts the expanded All tools view away. + if mode != Mode::AllTools { + self.all_tools_full = false; + } if mode == Mode::Edit { self.quick_tool = QuickTool::EditText; } else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { @@ -2298,6 +2306,15 @@ impl PdfCraftApp { } } + /// Fill the workspace with the expanded All tools view, entering All tools the way the mode + /// bar does: leaving Read restores its right panel and leaving Edit puts the editing tool down. + pub(crate) fn expand_all_tools(&mut self) { + if self.mode != Mode::AllTools { + self.switch_mode(Mode::AllTools, true); + } + self.all_tools_full = true; + } + /// Back to the Select tool from an editing tool. A paragraph still open in the editor is /// applied, as clicking away from it does; left open, its page would keep showing the /// paragraph's text removed with no editor over it. @@ -2316,10 +2333,6 @@ impl PdfCraftApp { /// Read mode hides the quick toolbar, so entering it also puts the current tool down. pub(crate) fn switch_mode(&mut self, mode: Mode, select_tools: bool) { let was_read = self.mode == Mode::Read; - // Another workspace puts the expanded All tools view away. - if mode != Mode::AllTools { - self.all_tools_full = false; - } if select_tools { self.select_mode(mode); } else { @@ -2730,10 +2743,7 @@ impl PdfCraftApp { _ => Some(Dialog::About), } } - ("tools", _) if value == "full" => { - self.mode = Mode::AllTools; - self.all_tools_full = true; - } + ("tools", _) if value == "full" => self.expand_all_tools(), ("tools", _) => { self.all_tools_full = false; self.all_tools_expanded = value != "collapsed"; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f4fbb397a6..ebddd320b5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -66,8 +66,7 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { // Labels distinct from the Combine grid's Expand and Collapse, which can show beside them. let (expand, close) = panel_header(ui, t, "All tools", Some(("maximize-2", tl!("Expand all tools")))); if expand { - app.all_tools_full = true; - app.mode = crate::Mode::AllTools; + app.expand_all_tools(); } if close { app.left_open = false; diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 8416e34607..f65da9ac6a 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -190,6 +190,51 @@ fn another_workspace_from_the_mode_option_puts_the_expanded_all_tools_away() { assert!(!app.all_tools_full, "coming back shows the sidebar, as the mode bar does"); } +#[test] +fn opening_a_document_puts_the_expanded_all_tools_away() { + use pdfcraft_ui_egui::Mode; + // All tools as the default workspace: no workspace change, but the document must show. + let mut app = PdfCraftApp::new(); + app.set_option("tools", "full").unwrap(); + app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + assert!(!app.all_tools_full, "the document shows, not the catalogue over it"); + // Read as the default workspace: coming back to All tools later shows the sidebar. + let mut app = PdfCraftApp::new(); + app.set_option("default-mode", "read").unwrap(); + app.set_option("tools", "full").unwrap(); + app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + assert_eq!(app.mode, Mode::Read); + assert!(!app.all_tools_full); +} + +#[test] +fn expanding_all_tools_from_read_mode_restores_the_right_panel() { + use pdfcraft_ui_egui::{Mode, RightPanel}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.state_mut().right = Some(RightPanel::Bookmarks); + h.state_mut().execute("view.read_mode"); + h.run_steps(3); + assert_eq!((h.state().mode, h.state().right), (Mode::Read, None), "Read mode hides the panel"); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + assert!(h.state().all_tools_full); + assert_eq!((h.state().mode, h.state().right), (Mode::AllTools, Some(RightPanel::Bookmarks))); +} + +#[test] +fn expanding_all_tools_from_edit_puts_edit_text_down() { + use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Edit").click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::EditText); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + assert!(h.state().all_tools_full); + assert_eq!((h.state().mode, h.state().quick_tool), (Mode::AllTools, QuickTool::Select)); + assert_eq!(h.state().left, LeftPanel::AllTools, "collapsing goes back to the All tools list"); +} + #[test] fn opening_a_pdf_shows_comments_and_bookmarks() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); From 8a8bb92d33f028a4e2b92c4412e8c7a335de2c8a Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:00:41 +0200 Subject: [PATCH 08/43] M3: choosing a tool or another tab puts the expanded All tools away The expanded view kept covering the workspace after a control client chose a tool (`tool=organize`) or went Home (`home`), so the chosen panel or the Home view never showed. The same held for a document tab click, Open Recent on an open file, and the Combine files tab. select_tool now clears the view, so every way of choosing a tool does. The view also remembers which tab was in front when it opened, and another tab coming forward puts it away, whichever path brought it there. Each new test was seen to fail with the fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/all_tools_ui.rs | 1 - crates/ui-egui/src/lib.rs | 16 +++++++++++++++ crates/ui-egui/tests/ui.rs | 33 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs index e9e0f6128a..71b4c16845 100644 --- a/crates/ui-egui/src/all_tools_ui.rs +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -98,7 +98,6 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); if let Some(id) = chosen { - app.all_tools_full = false; app.left_open = true; app.select_tool(id); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0cd9a29344..8272978545 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -583,6 +583,9 @@ pub struct PdfCraftApp { /// The All tools view fills the workspace (its sidebar's expand button). pub all_tools_full: bool, pub all_tools_query: String, + /// The tab that showed when the expanded All tools view opened ([`Self::shown_tab`]). Another + /// tab coming to the front (Home, a document, Combine files) puts the view away. + all_tools_tab: (Option, bool), pub recent: Vec, /// Preferences: reopen the files that were open when PdfCraft last closed (#442). pub reopen_last_session: bool, @@ -987,6 +990,7 @@ impl PdfCraftApp { all_tools_expanded: false, all_tools_full: false, all_tools_query: String::new(), + all_tools_tab: (None, false), recent: Vec::new(), reopen_last_session: false, last_session: Default::default(), @@ -2256,6 +2260,8 @@ impl PdfCraftApp { /// Enter a tool from the catalogue, Home, palette or control channel. Organize is a /// workspace, so opening its panel also opens the page grid (without toggling it off). pub(crate) fn select_tool(&mut self, id: &'static str) { + // The tool's panel shows, not the expanded All tools view over it. + self.all_tools_full = false; self.left = LeftPanel::Tool(id); self.reveal_tools(); if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) { @@ -2313,6 +2319,12 @@ impl PdfCraftApp { self.switch_mode(Mode::AllTools, true); } self.all_tools_full = true; + self.all_tools_tab = self.shown_tab(); + } + + /// The tab at the front: the active document, or else whether Combine files shows over Home. + fn shown_tab(&self) -> (Option, bool) { + (self.active.and_then(|i| self.views.get(i)).map(|v| v.id), self.combine_showing()) } /// Back to the Select tool from an editing tool. A paragraph still open in the editor is @@ -3400,6 +3412,10 @@ impl eframe::App for PdfCraftApp { .show(ui, |ui| panels::right_panel(self, ui)); } } + // Whichever way another tab came to the front, it shows instead of the expanded view. + if self.all_tools_full && self.all_tools_tab != self.shown_tab() { + self.all_tools_full = false; + } let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { if self.mode == Mode::Read { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index f65da9ac6a..ae32820fc8 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -207,6 +207,39 @@ fn opening_a_document_puts_the_expanded_all_tools_away() { assert!(!app.all_tools_full); } +#[test] +fn choosing_a_tool_puts_the_expanded_all_tools_away() { + use pdfcraft_ui_egui::LeftPanel; + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().set_option("tool", "organize").unwrap(); + h.run_steps(3); + assert!(!h.state().all_tools_full, "the tool's panel shows, not the catalogue"); + assert_eq!(h.state().left, LeftPanel::Tool("organize")); +} + +#[test] +fn another_tab_coming_forward_puts_the_expanded_all_tools_away() { + // Home, through the control option. + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + assert!(h.state().all_tools_full, "the document's own tab keeps it"); + h.state_mut().set_option("home", "").unwrap(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + h.get_by_label_contains("Welcome to PdfCraft"); + // Another document's tab. + let mut h = harness(|app| { + app.open_bytes("one.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + app.open_bytes("two.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + }); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + h.state_mut().active = Some(0); + h.run_steps(3); + assert!(!h.state().all_tools_full, "one.pdf shows, not the catalogue over it"); +} + #[test] fn expanding_all_tools_from_read_mode_restores_the_right_panel() { use pdfcraft_ui_egui::{Mode, RightPanel}; From fd971559e7ea07349e88c77a72773f794c4edc1d Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:29:03 +0200 Subject: [PATCH 09/43] M3: going Home puts the expanded All tools away with no document open With no document open the expanded view covers Home itself, so the control option `home` changed no tab and the view stayed up (CodeRabbit's remaining merge-risk note on 8a8bb92d). The option now puts the view away itself. `going_home_with_no_document_puts_the_expanded_all_tools_away` was seen to fail with the fix reverted. Also doc comments on the view's `show` and `tile` and the sidebar's `all_tools`, for CodeRabbit's docstring-coverage check. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/all_tools_ui.rs | 5 +++++ crates/ui-egui/src/lib.rs | 7 ++++++- crates/ui-egui/src/panels.rs | 2 ++ crates/ui-egui/tests/ui.rs | 10 ++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs index 71b4c16845..53aa7ebbd7 100644 --- a/crates/ui-egui/src/all_tools_ui.rs +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -36,6 +36,8 @@ fn matches(query: &str, group: &ToolGroup) -> Option> { (!items.is_empty()).then_some(items) } +/// Draw the view over the workspace: the title with Collapse, the search field, and each +/// category's tiles that match the search. Choosing a tile opens that tool's panel. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // A dialog or prompt over the view owns Escape. @@ -103,6 +105,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } +/// One tool's tile: its icon and name, then the `items` the search found it by or, when there are +/// none, its availability (a planned tool is greyed out with its milestone). A name or note cut +/// short shows in full on hover. fn tile(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, items: &[&'static str]) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8272978545..0e8a5a58c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2712,7 +2712,12 @@ impl PdfCraftApp { self.select_tool(g.id); } ("left", _) => self.left_open = value != "closed", - ("home", _) => self.active = None, + ("home", _) => { + self.active = None; + // With no document open the expanded All tools view covers Home itself, so no + // other tab comes forward to put it away. + self.all_tools_full = false; + } ("dialog", _) => { self.dialog = match value { "properties" => Some(Dialog::Properties(PropsTab::Description)), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ebddd320b5..c36b442410 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -62,6 +62,8 @@ pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, lead: Opt (go_back, close) } +/// The All tools sidebar: Expand (the full view) and Close, then the tool list, the first few +/// tools until View more shows them all. fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { // Labels distinct from the Combine grid's Expand and Collapse, which can show beside them. let (expand, close) = panel_header(ui, t, "All tools", Some(("maximize-2", tl!("Expand all tools")))); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index ae32820fc8..4ee1f06582 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -240,6 +240,16 @@ fn another_tab_coming_forward_puts_the_expanded_all_tools_away() { assert!(!h.state().all_tools_full, "one.pdf shows, not the catalogue over it"); } +#[test] +fn going_home_with_no_document_puts_the_expanded_all_tools_away() { + // Home is already the tab in front, so only the option itself can put the view away. + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().set_option("home", "").unwrap(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + h.get_by_label_contains("Welcome to PdfCraft"); +} + #[test] fn expanding_all_tools_from_read_mode_restores_the_right_panel() { use pdfcraft_ui_egui::{Mode, RightPanel}; From 795fb53c5d6b715ceb34f0860baf4904d0535b16 Mon Sep 17 00:00:00 2001 From: Max Abramowitz Date: Thu, 15 Oct 2026 12:57:11 -0400 Subject: [PATCH 10/43] M3: the community card on Home can be closed for the rest of the run The ArtCraft community card on Home gets a close button. Closing it hides the card until the next launch; the choice is deliberately not remembered, and the same links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/home.rs | 44 +++++++++++++++++++------------- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/it/links.rs | 13 ++++++++++ 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c2..dfc89b4525 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -281,6 +281,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged. - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. +- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. 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. diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 3e70f34e42..ddd60f0bde 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -29,26 +29,34 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .font(theme::regular(14.0)), ); ui.add_space(14.0); - egui::Frame::NONE - .fill(t.card) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(12)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - widgets::artcraft_mark(ui, 28.0); - ui.vertical(|ui| { - ui.label(egui::RichText::new(tl!("Join the ArtCraft community")).font(theme::semibold(15.0))); - ui.label(egui::RichText::new(tl!("Get help, share feedback and follow development on Discord.")).color(t.text_muted)); + if !app.community_card_closed { + egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(12)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + widgets::artcraft_mark(ui, 28.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("Join the ArtCraft community")).font(theme::semibold(15.0))); + ui.label(egui::RichText::new(tl!("Get help, share feedback and follow development on Discord.")).color(t.text_muted)); + }); + // Closed for this run only: the card is back on the next launch. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icons::button(ui, "x", 24.0, false, tl!("Close")).clicked() { + app.community_card_closed = true; + } + }); }); + ui.add_space(8.0); + if let Some(cmd) = widgets::community_links(ui) { + app.execute(cmd); + } }); - ui.add_space(8.0); - if let Some(cmd) = widgets::community_links(ui) { - app.execute(cmd); - } - }); - ui.add_space(22.0); + ui.add_space(22.0); + } egui::Frame::NONE .fill(t.card) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18e..5287858aa9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -834,6 +834,8 @@ pub struct PdfCraftApp { pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, + /// The home screen's community card was closed; it comes back next launch (not remembered). + pub community_card_closed: bool, /// A document asked to open this address; the user hasn't answered yet (#90, #91). pub pending_link: Option, /// A document just opened asks to be shown in Full Screen mode (`/PageMode /FullScreen`): @@ -1099,6 +1101,7 @@ impl PdfCraftApp { control: None, bookmark_rename: None, last_opened_url: None, + community_card_closed: false, pending_link: None, pending_full_screen: None, pending_remote_link: None, diff --git a/crates/ui-egui/tests/it/links.rs b/crates/ui-egui/tests/it/links.rs index b08036b11d..f08e9d4356 100644 --- a/crates/ui-egui/tests/it/links.rs +++ b/crates/ui-egui/tests/it/links.rs @@ -42,6 +42,19 @@ fn home_screen_links() { } } +#[test] +fn home_community_card_closes_for_this_run() { + let mut h = harness(|_| {}); + h.get_by_label("Join the ArtCraft community"); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().community_card_closed); + assert!(h.query_by_label("Join the ArtCraft community").is_none()); + assert!(h.query_by_label("Join our Discord").is_none()); + // Not remembered: a fresh app shows the card again. + assert!(!PdfCraftApp::new().community_card_closed); +} + #[test] fn about_dialog_shows_the_brand_and_links() { let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; From ff2962c2f4f2d76ec19961490df1abd0e7cad5c0 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:58:11 +0200 Subject: [PATCH 11/43] =?UTF-8?q?fix(ui):=20test=20that=20Properties=20?= =?UTF-8?q?=E2=96=B8=20Space=20Audit=20measures=20again=20after=20an=20edi?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab caches the audit per document and edit generation. Pin both halves: frames with the file unchanged keep the same rows, and an edit made while the tab is open (a new bookmark) shows up on the next frame. With the cache keyed on the document alone, the test fails. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/dialog_chrome.rs | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/ui-egui/tests/it/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index b6be218ecd..d33ea7baac 100644 --- a/crates/ui-egui/tests/it/dialog_chrome.rs +++ b/crates/ui-egui/tests/it/dialog_chrome.rs @@ -168,6 +168,40 @@ fn space_audit_tab_measures_the_file_and_stays_in_the_card() { } } +#[test] +fn space_audit_tab_measures_again_after_an_edit() { + use pdfcraft_engine::optimize::SpaceCategory; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app.set_option("dialog", "space-audit").unwrap(); + app + }); + h.run_steps(4); + let bookmarks = + |h: &Harness<'_, PdfCraftApp>| h.state().space_audit.iter().filter(|r| r.category == SpaceCategory::Bookmarks).map(|r| r.bytes).sum::(); + let before = h.state().space_audit.clone(); + assert_eq!(bookmarks(&h), 0, "the fixture has no outline: {before:?}"); + // Further frames with the file unchanged keep the same measurement. + h.run_steps(2); + assert_eq!(h.state().space_audit, before); + // An edit while the tab is open is measured on the next frame. + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { + parent: vec![], + index: 0, + title: "Chapter".into(), + page: 0, + left: None, + top: None, + }); + h.run_steps(2); + assert!(bookmarks(&h) > 0, "the new outline is counted: {:?}", h.state().space_audit); + // The table shows the new row (the rail's Bookmarks button has the same label). + let card = card(&h, "dialog"); + assert!(h.query_all_by_label("Bookmarks").any(|n| card.contains_rect(n.rect())), "a Bookmarks row in {card:?}"); +} + #[test] fn dialog_buttons_follow_the_content_after_a_taller_tab() { use pdfcraft_ui_egui::{Dialog, PropsTab}; From 45dccb22d03ea2aa9b59b387b47196a6edf5f4ce Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 23 Oct 2026 17:26:53 +0200 Subject: [PATCH 12/43] M3: All tools search finds menu commands; Escape stays with the palette Local CodeRabbit review follow-ups for the expanded All tools view: - The search field promises tools and commands, but an entry with a menu (Watermark: Add, Update, Remove) matched only by its own name, so "remove watermark" found nothing. Entries now also match by their menu commands' names, in English or the UI language. - With the command palette open over the view, Escape closed the view as well as the palette. The palette now owns Escape, as dialogs do. Each new test was seen to fail with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/all_tools_ui.rs | 19 +++++++++++++++---- crates/ui-egui/tests/it/ui.rs | 11 +++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs index 53aa7ebbd7..c8c510a11c 100644 --- a/crates/ui-egui/src/all_tools_ui.rs +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -25,14 +25,19 @@ fn label_hit(label: &str, translated: &str, query: &str, rtl: bool) -> bool { /// Whether `group` matches `query` (already trimmed and lower-cased), in English or the UI /// language. `None` is no match; otherwise the items that matched, empty when the tool's own -/// name did. +/// name did. An entry with a menu (Watermark: Add, Update, Remove) also matches by its commands' +/// names, so "remove watermark" finds it. fn matches(query: &str, group: &ToolGroup) -> Option> { let rtl = crate::i18n::current().rtl(); let hit = |label: &str| label_hit(label, tl!(label), query, rtl); if query.is_empty() || hit(group.label) { return Some(Vec::new()); } - let items: Vec<_> = group.sections.iter().flat_map(|s| s.items.iter()).map(|i| i.label).filter(|l| hit(l)).collect(); + let action_hit = |id: &&str| { + pdfcraft_engine::commands::command(id).is_some_and(|spec| label_hit(spec.label, &crate::i18n::menu_label(spec.id, spec.label), query, rtl)) + }; + let items: Vec<_> = + group.sections.iter().flat_map(|s| s.items.iter()).filter(|i| hit(i.label) || i.actions.iter().any(action_hit)).map(|i| i.label).collect(); (!items.is_empty()).then_some(items) } @@ -40,8 +45,8 @@ fn matches(query: &str, group: &ToolGroup) -> Option> { /// category's tiles that match the search. Choosing a tile opens that tool's panel. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - // A dialog or prompt over the view owns Escape. - if !app.modal_open() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // A dialog, prompt or the command palette over the view owns Escape. + if !app.modal_open() && !app.palette_open && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.all_tools_full = false; return; } @@ -173,6 +178,12 @@ mod tests { assert_eq!(matches("no such tool anywhere", group("organize")), None); } + #[test] + fn search_finds_an_entry_by_its_menus_commands() { + let found = matches("remove watermark", group("edit")).expect("Watermark's menu has Remove watermark"); + assert!(found.contains(&"Watermark"), "{found:?}"); + } + #[test] fn right_to_left_labels_match_a_query_typed_in_logical_order() { let shown = crate::bidi::display_rtl("تنظيم الصفحات"); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 056a7c3ddd..52e22017b3 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -179,6 +179,17 @@ fn escape_in_a_dialog_over_the_expanded_all_tools_leaves_the_view_open() { assert!(h.state().all_tools_full, "Escape belonged to the dialog"); } +#[test] +fn escape_in_the_command_palette_over_the_expanded_all_tools_leaves_the_view_open() { + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().palette_open = true; + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(!h.state().palette_open, "Escape closed the palette"); + assert!(h.state().all_tools_full, "and only the palette"); +} + #[test] fn another_workspace_from_the_mode_option_puts_the_expanded_all_tools_away() { let mut app = PdfCraftApp::new(); From be600950c98e958059cdfaf541225186256c4866 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 09:52:34 +0000 Subject: [PATCH 13/43] fix(comments): thread comment replies by /IRT reference, count comments like Acrobat Replies were matched to their parent by /NM across the whole document. /NM need not be unique: PyMuPDF names the first comment on every page `fitz-A0`, so every reply in such a file was filed under page 1's comment, and the other pages showed none. Both comment readers (render inspect on open, annot summaries after an edit) now resolve /IRT to the parent's page and /Annots position by object reference, and the Comments panel, comment properties, the canvas thread, the engine's comment summary and the comment_list tool thread by that. The Comments header now counts comments without their replies, as Acrobat does (28 rather than 44 for the reported file), and a reply's thread line runs from the avatar above it (the comment's or the previous reply's) to its own instead of a 12 px stub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KvugvPrjZtPVudhh7cMa54 --- ROADMAP.md | 1 + crates/annot/src/lib.rs | 18 ++- crates/automation/src/comments.rs | 18 +-- crates/engine/src/lib.rs | 15 +- crates/engine/src/tests.rs | 31 ++++ crates/render/src/inspect.rs | 227 +++++++++++++++------------ crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/comment_props.rs | 9 +- crates/ui-egui/src/comments.rs | 8 +- crates/ui-egui/src/comments_panel.rs | 51 +++--- crates/ui-egui/src/editing.rs | 4 +- crates/ui-egui/tests/it/comments.rs | 40 ++++- parity/acrobat-features.toml | 4 +- 13 files changed, 267 insertions(+), 164 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5634d6bf28..245eafca64 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44), and a reply's thread line runs from the avatar above it to its own. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 720d39cd80..7b3446cfcf 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2254,6 +2254,8 @@ pub struct Summary { pub modified: Option, pub name: Option, pub in_reply_to: Option, + /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). + pub parent: Option<(usize, usize)>, pub rect: [f32; 4], pub color: Option<[f32; 3]>, pub state: Option, @@ -2289,8 +2291,18 @@ pub fn is_comment_subtype(subtype: &str) -> bool { pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); let Ok(pages) = page_refs(doc) else { return out }; - for (page, p) in pages.iter().enumerate() { - for (index, entry) in annots(doc, *p).iter().enumerate() { + let lists: Vec> = pages.iter().map(|p| annots(doc, *p)).collect(); + // Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference. + let mut slots: std::collections::HashMap = std::collections::HashMap::new(); + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + if let Some(r) = entry.as_ref() { + slots.entry(r).or_insert((page, index)); + } + } + } + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { let obj = doc.resolve(entry); let Some(d) = obj.as_dict() else { continue }; let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; @@ -2326,6 +2338,7 @@ pub fn summaries(doc: &Document) -> Vec { _ => Vec::new(), }; let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&r).copied()); out.push(Summary { page, index, @@ -2337,6 +2350,7 @@ pub fn summaries(doc: &Document) -> Vec { modified: text_value(doc, d, b"M"), name: text_value(doc, d, b"NM"), in_reply_to, + parent, rect, color, state: text_value(doc, d, b"State"), diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 3d1b8d82dd..bd583aeaed 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -200,26 +200,24 @@ impl Automation { }; let roots: Vec = all .iter() - .filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p)) + .filter(|c| !c.is_reply() && only.is_none_or(|p| c.page == p)) .map(|c| { let mut v = view(c); // Every reply under this comment, at any depth, in document order. A reply to a // reply keeps its `/IRT` on the direct parent, so following one level would drop it. - let descends_from = |r: &Annotation, root: &str| -> bool { - let mut parent = r.in_reply_to.as_deref(); + // Parents are found by reference: `/NM` need not be unique. + let descends_from = |r: &Annotation, root: &Annotation| -> bool { + let mut parent = r.parent; for _ in 0..64 { - let Some(nm) = parent else { return false }; - if nm == root { + let Some(at) = parent else { return false }; + if at == (root.page, root.index) { return true; } - parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref()); + parent = all.iter().find(|x| (x.page, x.index) == at).and_then(|x| x.parent); } false }; - let thread: Vec<&Annotation> = match c.name.as_deref() { - Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(), - None => Vec::new(), - }; + let thread: Vec<&Annotation> = all.iter().filter(|r| r.is_reply() && descends_from(r, c)).collect(); let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()); v["status"] = json!(status); v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b97..cd9ec5b855 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2900,6 +2900,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec &str { /// pop-ups; the number is the comment's position on its page. pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { use std::fmt::Write; - let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect(); + let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| !a.is_reply() && a.state.is_none()).collect(); // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) { + fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &pdfcraft_render::Annotation, depth: usize) { use std::fmt::Write; if depth > 64 { return; } - for r in all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) { + for r in all.iter().filter(|r| r.state.is_none() && r.replies_to(parent)) { let indent = " ".repeat(depth + 1); let _ = writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); for line in r.contents.as_deref().unwrap_or("").lines() { let _ = writeln!(out, "{indent}{line}"); } - if let Some(nm) = r.name.as_deref() { - write_replies(out, all, nm, depth + 1); - } + write_replies(out, all, r, depth + 1); } } let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); @@ -5383,9 +5382,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { let _ = writeln!(out, "{c}"); } - if let Some(nm) = a.name.as_deref() { - write_replies(&mut out, all, nm, 0); - } + write_replies(&mut out, all, a, 0); out.push('\n'); } out diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..44ac16c345 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3419,6 +3419,37 @@ fn comments_lock_take_checkmarks_hide_and_summarize() { assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n"); } +/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every +/// page `fitz-A0`, which used to file every reply under page 1's comment. Both readers (inspect on +/// open, and the comment list the engine refreshes after an edit) must agree. +#[test] +fn replies_thread_by_reference_when_names_repeat_across_pages() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fitz.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens"); + let check = |s: &Session, replies: usize| { + let all = &s.get(id).unwrap().info.annotations; + let answer = all.iter().find(|a| a.contents.as_deref() == Some("ANSWER")).unwrap(); + assert_eq!(answer.parent, Some((1, 0)), "{answer:?}"); + let text = comment_summary("fitz.pdf", all, SummarySort::Page); + let (cover, second) = text.split_once("SECOND").unwrap(); + assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}"); + assert_eq!(second.matches(" Author:").count(), replies, "{text}"); + }; + check(&s, 1); + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "LATER".into(), author: "Delta".into() }).unwrap(); + check(&s, 2); +} + /// #820: the generated summary includes replies to replies, indented one level deeper. #[test] fn comment_summary_includes_replies_to_replies() { diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 81fad6b0b2..0490390163 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -6,6 +6,7 @@ //! are empty and `warnings` says why. use std::cell::OnceCell; +use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; @@ -417,9 +418,13 @@ pub struct Annotation { pub author: Option, pub contents: Option, pub modified: Option, - /// `/NM` of this annotation, and of the one it replies to (`/IRT`), for threading. + /// `/NM` of this annotation, and of the one it replies to (`/IRT`). pub name: Option, pub in_reply_to: Option, + /// The page and `/Annots` position of the annotation `/IRT` points at, found by object + /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF + /// names the first comment on every page `fitz-A0`. + pub parent: Option<(usize, usize)>, /// Rect in PDF user space: [x0, y0, x1, y1]. pub rect: [f32; 4], pub color: Option<[f32; 3]>, @@ -446,6 +451,16 @@ impl Annotation { pub fn is_mark(&self) -> bool { matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) } + + /// A reply (or status reply) to another annotation rather than a comment of its own. + pub fn is_reply(&self) -> bool { + self.in_reply_to.is_some() || self.parent.is_some() + } + + /// Whether this annotation's `/IRT` points at `other`. + pub fn replies_to(&self, other: &Annotation) -> bool { + self.parent == Some((other.page, other.index)) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1250,13 +1265,12 @@ impl<'a> Inspector<'a> { pages.sort_unstable_by_key(|&(_, page)| page); // An annotation, or a whole `/Annots` array, that several entries or pages list is on the // first of them (§12.5.2 allows one page per annotation). Both are told apart by the object - // a reference resolves to, since an indirect object may itself hold a reference. + // a reference resolves to, since an indirect object may itself hold a reference. Where each + // annotation is listed is collected before any is read, so a reply's `/IRT` finds its + // parent by reference even when the parent comes later. let mut seen_arrays = HashSet::new(); - let mut seen_annotations = HashSet::new(); - let mut kept = 0usize; - // Once the comment budget is spent, comments are no longer built, but links and attachments - // are still listed. - let mut comments_full = false; + let mut slots: HashMap = HashMap::new(); + let mut entries = Vec::new(); for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; if let Ok(annots) = page_dict.get(b"Annots") @@ -1267,105 +1281,112 @@ impl<'a> Inspector<'a> { } let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; for (index, a) in annots.iter().enumerate() { - if let Some(r) = self.resolved_id(a) - && !seen_annotations.insert(r) - { - continue; + if let Some(r) = self.resolved_id(a) { + let Entry::Vacant(slot) = slots.entry(r) else { continue }; + slot.insert((page, index)); } - let Some(d) = self.dict(a) else { continue }; - let Some(subtype) = self.name(d, b"Subtype") else { continue }; - if subtype == "FileAttachment" - && let Ok(fs) = d.get(b"FS") - { - let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); - info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); - } - if subtype == "Link" { - if let Some(link) = self.link(page, index, d, text_budget) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { - info.has_javascript = true; - } - info.links.push(link); - } - continue; - } - // Not comments: form widgets, pop-ups and non-markup annotations such as the - // Screen annotation that drives a LaTeX `animate` player (keep in step with - // `pdfcraft_annot::is_comment_subtype`). - if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") - { - continue; - } - if comments_full { - continue; - } - let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } - _ => None, - }; - // A text box's /C is its background; its text colour is in /DA. - if subtype == "FreeText" - && let Some(da) = self.text(d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { - Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() - } - _ => Vec::new(), - }; - let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); - let fill_sign = self.is_fill_sign(d, &subtype); - // Keep in step with `pdfcraft_annot::summaries`. - let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; - let points = match d.get(key).map(|o| self.resolve(o)) { - Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { - let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); - if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } - } - _ => Vec::new(), - }; - let annotation = Annotation { - page, - subtype, - author: self.text(d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep - // in step with `pdfcraft_annot::summaries`). - contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: self.text(d, b"M").map(|m| pretty_date(&m)), - name: self.text(d, b"NM"), - in_reply_to, - rect, - color, - index, - state: self.text(d, b"State"), - quads, - locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, - intent: self.name(d, b"IT"), - fill_sign, - points, - }; - kept = kept.saturating_add(retained(&annotation)); - if kept > MAX_ANNOTATION_BYTES { - comments_full = true; - continue; - } - info.annotations.push(annotation); + entries.push((page, index, a)); } } + let mut kept = 0usize; + // Once the comment budget is spent, comments are no longer built, but links and attachments + // are still listed. + let mut comments_full = false; + for (page, index, a) in entries { + let Some(d) = self.dict(a) else { continue }; + let Some(subtype) = self.name(d, b"Subtype") else { continue }; + if subtype == "FileAttachment" + && let Ok(fs) = d.get(b"FS") + { + let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); + info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); + } + if subtype == "Link" { + if let Some(link) = self.link(page, index, d, text_budget) { + if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { + info.has_javascript = true; + } + info.links.push(link); + } + continue; + } + // Not comments: form widgets, pop-ups and non-markup annotations such as the + // Screen annotation that drives a LaTeX `animate` player (keep in step with + // `pdfcraft_annot::is_comment_subtype`). + if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") { + continue; + } + if comments_full { + continue; + } + let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); + let mut color = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) if c.len() == 3 => { + let f = |i: usize| c[i].as_float().unwrap_or(0.0); + Some([f(0), f(1), f(2)]) + } + _ => None, + }; + // A text box's /C is its background; its text colour is in /DA. + if subtype == "FreeText" + && let Some(da) = self.text(d, b"DA") + { + let t: Vec<&str> = da.split_whitespace().collect(); + if let Some(i) = t.iter().position(|x| *x == "rg") + && i >= 3 + { + let f = |k: usize| t[k].parse::().unwrap_or(0.0); + color = Some([f(i - 3), f(i - 2), f(i - 1)]); + } + } + let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { + Ok(Object::Array(q)) => { + let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + v.as_chunks::<8>().0.to_vec() + } + _ => Vec::new(), + }; + let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); + let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied()); + let fill_sign = self.is_fill_sign(d, &subtype); + // Keep in step with `pdfcraft_annot::summaries`. + let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; + let points = match d.get(key).map(|o| self.resolve(o)) { + Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { + let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); + if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } + } + _ => Vec::new(), + }; + let annotation = Annotation { + page, + subtype, + author: self.text(d, b"T"), + // Acrobat ends lines with CR, which the interface would draw as nothing (keep + // in step with `pdfcraft_annot::summaries`). + contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), + modified: self.text(d, b"M").map(|m| pretty_date(&m)), + name: self.text(d, b"NM"), + in_reply_to, + parent, + rect, + color, + index, + state: self.text(d, b"State"), + quads, + locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, + intent: self.name(d, b"IT"), + fill_sign, + points, + }; + kept = kept.saturating_add(retained(&annotation)); + if kept > MAX_ANNOTATION_BYTES { + comments_full = true; + continue; + } + info.annotations.push(annotation); + } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1af2a6a4f3..0c4104fe99 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2271,7 +2271,7 @@ impl DocView { /// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures) /// show none: they are page content being edited, not comments. fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { - if a.in_reply_to.is_some() || a.fill_sign { + if a.is_reply() || a.fill_sign { return None; } let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); @@ -5155,7 +5155,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { .info .annotations .iter() - .any(|a| a.page == page && a.index == annotation && a.in_reply_to.is_none() && comments::made_by(a, tool)) + .any(|a| a.page == page && a.index == annotation && !a.is_reply() && comments::made_by(a, tool)) }) { app.apply_edit(Edit::StyleAnnotation { @@ -5737,6 +5737,7 @@ mod tests { modified: None, name: None, in_reply_to: None, + parent: None, rect: [0.0; 4], color: None, index: 0, diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index f66885d361..38fd2c4117 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -98,7 +98,7 @@ impl PdfCraftApp { pub fn make_props_default(&mut self, page: usize, index: usize, p: &CommentProps) { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; - let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.in_reply_to.is_none()) else { return }; + let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return }; let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { @@ -167,12 +167,11 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .and_then(|(_, id)| app.session.get(id)) .zip(app.comment_props.as_ref()) .map(|(doc, d)| { - let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none()); - let nm = me.and_then(|a| a.name.clone()); + let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply()); doc.info .annotations .iter() - .filter(|r| r.state.is_some() && nm.is_some() && r.in_reply_to == nm) + .filter(|r| r.state.is_some() && me.is_some_and(|a| r.replies_to(a))) .map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default())) .collect() }) @@ -180,7 +179,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let default_tool = app.comment_props.as_ref().and_then(|d| { let (_, id) = app.active_ids()?; let doc = app.session.get(id)?; - let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none())?; + let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?; crate::comments::tool_for_default(a, d.edited.endings.as_deref()) }); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index c832c2c1a5..0ff3219788 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -624,7 +624,7 @@ impl PageCx<'_> { /// Top-level comments on this page, in paint order. fn comments(&self) -> impl Iterator { - self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup") + self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && !a.is_reply() && a.subtype != "Popup") } /// Where a comment is on screen: one rectangle, or one per marked line for text markup. @@ -1843,7 +1843,7 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc /// Items of the canvas context menu for the selected comment (or the page). pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { let mut action = None; - let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && a.in_reply_to.is_none())); + let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply())); match selected { Some(a) => { let (page, index) = (a.page, a.index); @@ -1888,7 +1888,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo } }); }); - let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| a.name.is_some() && r.in_reply_to == a.name).collect(); + let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| r.replies_to(a)).collect(); let marked = crate::comments_panel::is_marked(&thread); if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() { action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() }))); @@ -2397,6 +2397,8 @@ mod tests { modified: None, name: None, in_reply_to: None, + parent: None, + grouped: false, rect: [10.0, 10.0, 50.0, 50.0], color: None, index: 0, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index ff85704568..70f51db407 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -87,9 +87,10 @@ pub fn is_marked(thread: &[&Annotation]) -> bool { thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")) } -/// The number shown in the header: comments and their replies (not status changes). +/// The number shown in the header: comments, not their replies or status changes (as Acrobat +/// counts them). pub fn count(info: &DocInfo) -> usize { - info.annotations.iter().filter(|a| a.state.is_none()).count() + info.annotations.iter().filter(|a| !a.is_reply() && a.state.is_none()).count() } fn matches(a: &Annotation, q: &str) -> bool { @@ -157,25 +158,28 @@ pub(crate) fn show( return edit; } let query = view.comments.search.clone().filter(|q| !q.trim().is_empty()); - let mut replies: HashMap<&str, Vec<&Annotation>> = HashMap::new(); + let mut replies: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); let mut page_counts: HashMap = HashMap::new(); - // Keep global name matching and source reply order. Page badges count roots before filtering. + // Replies find their parent by reference (`/NM` need not be unique) and keep source order. + // Page badges count roots before filtering. for a in &info.annotations { - if let Some(parent) = a.in_reply_to.as_deref() { - replies.entry(parent).or_default().push(a); + if a.is_reply() { + if let Some(parent) = a.parent { + replies.entry(parent).or_default().push(a); + } } else { let count = page_counts.entry(a.page).or_default(); *count = count.saturating_add(1); } } - let replies_of = |a: &Annotation| -> &[&Annotation] { a.name.as_deref().and_then(|name| replies.get(name)).map(Vec::as_slice).unwrap_or(&[]) }; + let replies_of = |a: &Annotation| -> &[&Annotation] { replies.get(&(a.page, a.index)).map(Vec::as_slice).unwrap_or(&[]) }; let cv = &view.comments; let status_of = |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); let mut roots: Vec<&Annotation> = info .annotations .iter() - .filter(|a| a.in_reply_to.is_none()) + .filter(|a| !a.is_reply()) .filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q)))) .filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a))) .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) @@ -301,8 +305,9 @@ fn card( .show(ui, |ui| { ui.set_width(ui.available_width()); let mut buttons_left = f32::INFINITY; + let mut above = egui::Rect::NOTHING; let head = ui.horizontal(|ui| { - avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); + above = avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); let author = a.author.as_deref().map_or_else(|| tl!("Unknown author").into(), crate::bidi::visual_auto); ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text)); if let Some(m) = &a.modified { @@ -373,11 +378,13 @@ fn card( }); } }); - // Replies, joined to the parent by a thread line. + // Replies, each joined to the avatar above it (the comment's or the previous reply's) + // by a thread line that runs the whole gap. for r in &replies { ui.add_space(8.0); - let resp = ui.horizontal(|ui| { - avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint); + let mut me = egui::Rect::NOTHING; + ui.horizontal(|ui| { + me = avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint); ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 2.0; ui.horizontal(|ui| { @@ -390,8 +397,12 @@ fn card( widgets::wrapped_text(ui, r.contents.as_deref().unwrap_or(""), theme::regular(12.5), t.text, false); }); }); - let x = resp.response.rect.left() + 12.0; - ui.painter().vline(x, (resp.response.rect.top() - 14.0)..=(resp.response.rect.top() - 2.0), Stroke::new(1.5, t.border)); + // The circles are 12 px in radius; leave a 1 px gap at each end. + let (top, bottom) = (above.center().y + 13.0, me.center().y - 13.0); + if above.is_positive() && top < bottom { + ui.painter().vline(me.center().x, top..=bottom, Stroke::new(1.5, t.border)); + } + above = me; } if selected && allowed { ui.add_space(8.0); @@ -499,7 +510,8 @@ fn card( } /// A 26 pt round badge: a type glyph (comments) or initials (replies). -fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) { +/// A round avatar; returns where it was drawn. +fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) -> egui::Rect { let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); let bg = if t.dark() { Color32::from_gray(0x55) } else { Color32::from_gray(0xD8) }; ui.painter().circle_filled(r.center(), 12.0, bg); @@ -509,6 +521,7 @@ fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, if let Some(s) = text { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, s, theme::semibold(10.5), t.text); } + r } /// Darken very light annotation colours (e.g. note yellow) so their glyph stays legible. @@ -527,15 +540,13 @@ fn legible(c: Color32, t: &Tokens) -> Color32 { pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView, hidden: bool) -> Option<&'static str> { let mut command = None; let cv = &mut view.comments; - let mut types: Vec = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| kind_label(a).to_string()).collect(); + let mut types: Vec = info.annotations.iter().filter(|a| !a.is_reply()).map(|a| kind_label(a).to_string()).collect(); types.sort(); types.dedup(); - let mut authors: Vec = - info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| a.author.clone().unwrap_or_default()).collect(); + let mut authors: Vec = info.annotations.iter().filter(|a| !a.is_reply()).map(|a| a.author.clone().unwrap_or_default()).collect(); authors.sort(); authors.dedup(); - let mut colors: Vec<(String, Option<[f32; 3]>)> = - info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| (color_key(a), a.color)).collect(); + let mut colors: Vec<(String, Option<[f32; 3]>)> = info.annotations.iter().filter(|a| !a.is_reply()).map(|a| (color_key(a), a.color)).collect(); colors.sort_by(|a, b| a.0.cmp(&b.0)); colors.dedup_by(|a, b| a.0 == b.0); let active = !(cv.hidden_types.is_empty() diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2fdc2e9d02..1b036ba33e 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -155,7 +155,7 @@ impl PdfCraftApp { } Edit::AddAnnotation(a) => { // Select the new comment (appended last among the page's comments). - let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max(); + let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); view.comments.selected = newest.map(|n| (a.page, n)); view.comments.reveal = true; // A new highlight opens its note for typing straight away, as in Acrobat, @@ -500,7 +500,7 @@ impl PdfCraftApp { let newest = self .session .get(view.id) - .and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && a.in_reply_to.is_none()).map(|a| a.index).max()); + .and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && !a.is_reply()).map(|a| a.index).max()); view.comments.selected = newest.map(|index| (page, index)); view.comments.reveal = true; self.quick_tool = crate::QuickTool::Select; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c75163..ea22c98a04 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1085,8 +1085,9 @@ fn the_panel_filters_and_sorts() { assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added"); } -// Contributor-original PDF. Two roots deliberately share /NM. Their replies are on -// another page, with dates opposite to source order. An unnamed root has no thread. +// Contributor-original PDF. Two roots deliberately share /NM; the replies (on another page, +// with dates opposite to source order) point at the first by reference. An unnamed root has +// no thread. const REVIEW_FIXTURE: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj @@ -1107,7 +1108,7 @@ trailer << /Root 1 0 R >> %%EOF"; #[test] -fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() { +fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() { let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { app.set_option("panel", "comments").unwrap(); app.views[0].comments.search = Some("needle".into()); @@ -1129,14 +1130,16 @@ fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() h.state_mut().views[0].comments.hidden_authors.clear(); h.run_steps(2); - h.get_by_label("Duplicate root"); - assert_eq!(h.query_all_by_label("Needle first reply").count(), 2, "duplicate names share the same global thread"); + // The replies point at the first root: sharing its /NM doesn't give the other its thread + // (or a search match on that thread). + assert_eq!(h.query_all_by_label("Duplicate root").count(), 0); + assert_eq!(h.query_all_by_label("Needle first reply").count(), 1); h.state_mut().views[0].comments.search = Some("status-only token".into()); h.state_mut().views[0].comments.hidden_statuses.push("Rejected".into()); h.state_mut().views[0].comments.hidden_checks.push("Checked".into()); h.run_steps(2); h.get_by_label("First root"); - h.get_by_label("Duplicate root"); + assert_eq!(h.query_all_by_label("Duplicate root").count(), 0); h.state_mut().views[0].comments.hidden_statuses.push("Accepted".into()); h.run_steps(2); @@ -1153,6 +1156,31 @@ fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "an unnamed root has no replies"); } +// Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named +// `fitz-A0`, so only the `/IRT` reference tells which comment a reply belongs to. +const PER_PAGE_NAMES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (Cover note) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (Second page note) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply on page two) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A2) /IRT 6 0 R /T (Beta) /Contents (Answer on page two) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn replies_follow_irt_references_when_names_repeat_on_every_page() { + let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + let reply = h.get_by_label("Reply on page two"); + assert!(reply.rect().top() > h.get_by_label("Second page note").rect().top(), "the reply sits under page 2's comment"); + assert!(h.get_by_label("Page 2").rect().top() < reply.rect().top(), "not under page 1's comment, which shares the /NM"); + h.get_by_label("Answer on page two"); + // The header counts the two comments, not their replies (Acrobat says 2, not 4). + assert_eq!(h.query_all_by_label("4").count(), 0); +} + #[test] fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() { let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4af0cd98c2..69c02f04e4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3560,8 +3560,8 @@ milestone = "M3" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand"] -notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" +evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages"] +notes = "cards grouped by page with replies (threaded by the `/IRT` reference, so files whose `/NM` repeats per page, as PyMuPDF writes them, thread correctly; the header counts comments without replies, as Acrobat does), status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" [[feature]] id = "comment.appearance-generation" From c2fd321ce3c3a1780127319eb0308a1046ce4d0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:17:53 +0000 Subject: [PATCH 14/43] fix(comments): show the reply count under the Comments header The header keeps Acrobat's count of comments and adds the replies in small text below it ("16 replies"), left out when there are none and counting the whole document rather than the current search. Status changes and checkmarks are not replies. The plural label is in all 33 catalogues with each language's plural forms; ATTRIBUTION.toml records the new hashes and that the label is an AI-assisted translation not yet reviewed by native speakers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KvugvPrjZtPVudhh7cMa54 --- ATTRIBUTION.md | 66 +++++++------- ATTRIBUTION.toml | 132 +++++++++++++-------------- ROADMAP.md | 2 +- crates/ui-egui/src/comments_panel.rs | 5 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/ckb.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/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.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/ko.tsv | 1 + crates/ui-egui/src/i18n/lo.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/nb.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ro.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/sq.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/th.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/panels.rs | 19 +++- crates/ui-egui/tests/it/comments.rs | 26 +++++- 39 files changed, 178 insertions(+), 105 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 593ae6e685..3645cc3fda 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,33 +8,33 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -285,17 +285,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | -| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | ## Compiled in through dependencies (5) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 4be205f6ed..aad917de98 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2,12 +2,12 @@ path = "crates/ui-egui/src/i18n/el.tsv" title = "Greek interface translation catalog" author = "Camthalion77 and contributors" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" +sha256 = "e1e52854ba3c24e27b53a6f814553a45925ec59820e5d4d99fe2a57655aa3339" # 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`). @@ -25,286 +25,286 @@ sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" path = "crates/ui-egui/src/i18n/zh-hans.tsv" title = "Simplified Chinese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59" +sha256 = "4ac6d601e15b7124b9b9086ef64c07c21913b7df6d9c50bc5aa1b6bf7a35518d" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" title = "Traditional Chinese (Taiwan) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218" +sha256 = "39ba85747f81d3c13a4b9b731653b5846c5dbec62f8e933701f7f53aea75dd7a" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" title = "Czech interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" +sha256 = "0918af46ebec17793c95db826dd3c5320dfbefc370179e61e82a226286d9b341" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" title = "Brazilian Portuguese interface translation catalog" author = "André Cardoso (andrecards), PdfCraft contributors" -source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" +sha256 = "9e63cddf19952d069a73015c302855022a82aa630e0f7051e43198b5ae0a0354" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" +sha256 = "77d63f5ab27878b2c2119e05afc6b56b8e81bdee69a498d60d00be3919066637" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" title = "French interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279" +sha256 = "4e6071f997faaaafec077ac10ddf88f54a24e18aeabbccedeb04904e2ced1441" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" title = "German interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a" +sha256 = "5c1d19bdf1624cd3194e50ee7993357fd24c22cd9b846868687db84634a3281f" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" title = "Lao interface translation catalog" author = "@bounkirdni-2025" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed" +sha256 = "626329af32954bc02108187a25b2b0ee6c7b14c046497269d254d93406714561" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" title = "Telugu interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6" +sha256 = "980d97209f373d842a3c6165c7702bc247d11b9b7ff9d2d9b2657e5849ed2def" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" title = "Italian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" +sha256 = "116228f89bd0d709a5dbf13b79d08307581544c3f55dea5dbf3a6aa11689c5f9" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" +sha256 = "c351967f50288b572ab90c943a6c7cd6589aee2b494e2c885a0050354616d907" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" title = "Russian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87" +sha256 = "f4115c838b0f54aa57bc11b91fc2e9257dba53ea07222dcc8e430091eb0443df" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" title = "Hungarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc" +sha256 = "a1e81d0e01e14d478569dc198749856f434e420a5b55ac2a9b9592e02e4c247a" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" title = "Bulgarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244" +sha256 = "c28196c5a47ae9aa29e3edb4e1c8602d40ee86da9d5b7c1913f0fb91fa9eb22f" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" title = "Kazakh interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5" +sha256 = "566e1ff35feb819983dffa31debc57ff766f44dc3f373cfe2b1042b3588bc184" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" title = "Japanese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d" +sha256 = "bb6959b995cf55b3fd0c80508065a06acb5bc38967c4584020a2e19e7e0bf23f" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" title = "Ukrainian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52" +sha256 = "55b325e50466f13e32655fc5562a5ba40cf9866a3663ba67a8d6510e70214baa" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" title = "Arabic interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1" +sha256 = "d2a2d4943d297ab85027f54035fcdf4a7dd06711fad8fb89245d3ac6ad6e0696" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" title = "Central Kurdish (Sorani) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72" +sha256 = "16c1d2ca75cc0848c738cd2b1b05fa822dafae5a16045ba3c8acb053c5e68095" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" title = "Turkish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d" +sha256 = "b86b12bf280bd17cfa91c115a40ac3805424ff03bc36d991aa108a7983d8ec62" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" +sha256 = "fda08c4b7c4fd9e86c66b27eb14b5a981cc45aa628c1e960ecfd4433a746f326" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" title = "Hindi interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa" +sha256 = "53910a1edc9fdba3595d96b34d1da2acfea93d645516fb36da45341ae4e93677" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886" +sha256 = "a65d03e5e27fd19cd7774aaa6971af65ebc11b32c20a3b5a6afa398bb93615b4" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339" +sha256 = "288e2e7f30b738450b3d0c25131e6388ca28d1432d1112d85e69f279e93fe356" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" title = "Romanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8e76922ebf2c707bf4a67a2df0940287b560ce012db367dd9c39fbe8e409cc1b" +sha256 = "e5387fddd56ba9c43e58f1ad92d985e9f6d3f4289336fe701caf679c92260372" [[asset]] path = "crates/ui-egui/src/i18n/fi.tsv" title = "Finnish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" +sha256 = "24556b6b93d24a42799a01777fc4f902cd500847b0c3083e559fe0e0db3f6f30" [[asset]] path = "assets/icons/accessibility.svg" @@ -3421,23 +3421,23 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" +sha256 = "66eeb2ea187233ec19892271fbf1097d3698a9795f7f2c40245544eb11394292" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" title = "Slovene interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" 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 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f" +sha256 = "c99143386d939d3219dd541fcde057261a94bc0ffc9516cd952a650543fe5532" [[asset]] @@ -3455,12 +3455,12 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" path = "crates/ui-egui/src/i18n/nb.tsv" title = "Norwegian Bokmål interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0" +sha256 = "8da2873e513f558c9bb6143498b7fdfdb8fe759c4771bfe3cc2df556fd23bbf3" [[asset]] path = "assets/icons/superscript.svg" @@ -3510,31 +3510,31 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" path = "crates/ui-egui/src/i18n/sq.tsv" title = "Albanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9" +sha256 = "08cef06c47b76c0cacdf8bea14547fd11938b07ca3adf0ed88370c9ad0164467" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" title = "Thai interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02" +sha256 = "6b16edfad5b67e143127fd2112b30a9a859228f1ea38371c3d21b31c485bb27f" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" title = "Catalan interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" +sha256 = "eda976052a1de1683c4d0142f20dbd5970e0e3af3ab5174e9f6c4089be958042" diff --git a/ROADMAP.md b/ROADMAP.md index 245eafca64..95be72290d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,7 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. -- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44), and a reply's thread line runs from the avatar above it to its own. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 70f51db407..ede5522fa5 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -93,6 +93,11 @@ pub fn count(info: &DocInfo) -> usize { info.annotations.iter().filter(|a| !a.is_reply() && a.state.is_none()).count() } +/// The replies under the header's count (not status changes or checkmarks). +pub fn reply_count(info: &DocInfo) -> usize { + info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none()).count() +} + fn matches(a: &Annotation, q: &str) -> bool { any_contains([a.author.as_deref(), a.contents.as_deref(), Some(kind_label(a))].into_iter().flatten(), q) } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba553..c0e6ab85f7 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2791,3 +2791,4 @@ certificate Version الإصدار field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name} two cells would both be named {name} ستحمل خليتان الاسم نفسه {name} a field named {name} already exists يوجد حقل باسم {name} بالفعل +@plural {n} reply|{n} replies {n} رد|{n} رد|{n} ردّان|{n} ردود|{n} ردًا|{n} رد diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a936..d21743411a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2478,3 +2478,4 @@ animation effects Off Изключени field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name} two cells would both be named {name} две клетки биха имали едно и също име {name} a field named {name} already exists вече съществува поле с име {name} +@plural {n} reply|{n} replies {n} отговор|{n} отговора diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b84..b0d7fe5273 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2774,3 +2774,4 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} +@plural {n} reply|{n} replies {n} resposta|{n} respostes diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a1..2f17db6002 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2713,3 +2713,4 @@ certificate Version وەشان Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی Pages selected پەڕە هەڵبژێردراوەکان Page properties تایبەتمەندییەکانی پەڕە +@plural {n} reply|{n} replies {n} وەڵام|{n} وەڵام diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66cac29aaf..d230171fac 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2822,3 +2822,4 @@ certificate Version Verze field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name} two cells would both be named {name} dvě buňky by se jmenovaly {name} a field named {name} already exists pole s názvem {name} už existuje +@plural {n} reply|{n} replies {n} odpověď|{n} odpovědi|{n} odpovědí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452f..6caf50ea36 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2516,3 +2516,4 @@ animation effects Off Aus field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name} two cells would both be named {name} zwei Zellen hätten beide den Namen {name} a field named {name} already exists ein Feld namens {name} ist bereits vorhanden +@plural {n} reply|{n} replies {n} Antwort|{n} Antworten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664ac..ed34f5c6c4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2521,3 +2521,4 @@ animation effects Off Ανενεργά field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name} two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name} a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name} +@plural {n} reply|{n} replies {n} απάντηση|{n} απαντήσεις diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73b0ffbf18..d48c53e6ad 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2838,3 +2838,4 @@ theme Appearance Aspecto field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name} two cells would both be named {name} dos celdas se llamarían {name} a field named {name} already exists ya existe un campo llamado {name} +@plural {n} reply|{n} replies {n} respuesta|{n} respuestas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6e..f9d4456ce7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2783,3 +2783,4 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa +@plural {n} reply|{n} replies {n} vastaus|{n} vastausta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56d..0dfc96a5da 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2781,3 +2781,4 @@ certificate Version Version field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name} two cells would both be named {name} deux cellules porteraient le nom {name} a field named {name} already exists un champ nommé {name} existe déjà +@plural {n} reply|{n} replies {n} réponse|{n} réponses diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec6883..0295a3cb30 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2750,3 +2750,4 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है +@plural {n} reply|{n} replies {n} जवाब diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629cc..6e36ddb012 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2514,3 +2514,4 @@ animation effects Off Ki field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name} two cells would both be named {name} két cella neve is {name} lenne a field named {name} already exists már van {name} nevű mező +@plural {n} reply|{n} replies {n} válasz|{n} válasz diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e1..7387e5a43e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2751,3 +2751,4 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada +@plural {n} reply|{n} replies {n} balasan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb0874..c775fdbd2d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2747,3 +2747,4 @@ certificate SHA-256 digest Impronta SHA-256 field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name} two cells would both be named {name} due celle si chiamerebbero entrambe {name} a field named {name} already exists esiste già un campo chiamato {name} +@plural {n} reply|{n} replies {n} risposta|{n} risposte diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d3641..92ba262917 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2515,3 +2515,4 @@ animation effects Off オフ field names can't contain a period: {name} フィールド名にピリオドは使えません: {name} two cells would both be named {name} 2 つのセルが同じ名前 {name} になります a field named {name} already exists {name} という名前のフィールドは既にあります +@plural {n} reply|{n} replies {n} 件の返信 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34a..1f8b32794b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2513,3 +2513,4 @@ animation effects Off Өшірулі field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name} two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді a field named {name} already exists {name} атты өріс бар +@plural {n} reply|{n} replies {n} жауап|{n} жауап diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19b..1b81853650 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2750,3 +2750,4 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 +@plural {n} reply|{n} replies 답글 {n}개 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f643..0de634d676 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2429,3 +2429,4 @@ animation effects Off ປິດ Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ Pages selected ໜ້າທີ່ເລືອກ Page properties ຄຸນສົມບັດໜ້າ +@plural {n} reply|{n} replies ການຕອບກັບ {n} ລາຍການ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e2476f1963..9812077ef9 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2776,3 +2776,4 @@ theme Appearance Išvaizda a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0 +@plural {n} reply|{n} replies {n} atsakymas|{n} atsakymai|{n} atsakymų diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec2..9bf310cd9f 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2694,3 +2694,4 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. +@plural {n} reply|{n} replies {n} svar|{n} svar diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6836f3e0a3..efc9f1832f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2611,3 +2611,4 @@ animation effects Off Wyłączone field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name} two cells would both be named {name} dwie komórki miałyby nazwę {name} a field named {name} already exists pole o nazwie {name} już istnieje +@plural {n} reply|{n} replies {n} odpowiedź|{n} odpowiedzi|{n} odpowiedzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dcbebae958..a20da2ad23 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2800,3 +2800,4 @@ theme Appearance Aparência a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas the first row number can be at most {max} o número da primeira linha pode ser no máximo {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0 +@plural {n} reply|{n} replies {n} resposta|{n} respostas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 2bd680f91e..5d7440ed75 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2774,3 +2774,4 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} +@plural {n} reply|{n} replies {n} răspuns|{n} răspunsuri|{n} de răspunsuri diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba6..df56fa7497 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2428,3 +2428,4 @@ animation effects Off Выкл field names can't contain a period: {name} имена полей не могут содержать точку: {name} two cells would both be named {name} две ячейки получили бы одно имя {name} a field named {name} already exists поле с именем {name} уже существует +@plural {n} reply|{n} replies {n} ответ|{n} ответа|{n} ответов diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7d..2b5ab0cf05 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2520,3 +2520,4 @@ animation effects Off Izklopljeni field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name} two cells would both be named {name} dve celici bi imeli enako ime {name} a field named {name} already exists polje z imenom {name} že obstaja +@plural {n} reply|{n} replies {n} odgovor|{n} odgovora|{n} odgovori|{n} odgovorov diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a3..c3bb902e57 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2500,3 +2500,4 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} +@plural {n} reply|{n} replies {n} përgjigje|{n} përgjigje diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4f..7c5b0b85a0 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2424,3 +2424,4 @@ animation effects Off ఆఫ్ field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name} two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది +@plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757c..e13480dc3e 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2681,3 +2681,4 @@ key Space Space Page properties คุณสมบัติหน้า The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ +@plural {n} reply|{n} replies การตอบกลับ {n} รายการ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b88638..881bc4e234 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2511,3 +2511,4 @@ animation effects Off Kapalı field names can't contain a period: {name} alan adları nokta içeremez: {name} two cells would both be named {name} iki hücrenin de adı {name} olurdu a field named {name} already exists {name} adlı bir alan zaten var +@plural {n} reply|{n} replies {n} yanıt diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765c..e77e5fe05d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2537,3 +2537,4 @@ animation effects Off Вимкнено field names can't contain a period: {name} імена полів не можуть містити крапку: {name} two cells would both be named {name} дві клітинки мали б однакове ім'я {name} a field named {name} already exists поле з іменем {name} уже існує +@plural {n} reply|{n} replies {n} відповідь|{n} відповіді|{n} відповідей diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87abe51290..1c4c72b61f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2780,3 +2780,4 @@ key Space Space a table has 1 to {max} columns một bảng có từ 1 đến {max} cột the first row number can be at most {max} số hàng đầu tiên tối đa là {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 +@plural {n} reply|{n} replies {n} câu trả lời diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df88..837c929c7d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2479,3 +2479,4 @@ animation effects Off 关 field names can't contain a period: {name} 字段名称不能包含句点:{name} two cells would both be named {name} 两个单元格的名称都会是 {name} a field named {name} already exists 已存在名为 {name} 的字段 +@plural {n} reply|{n} replies {n} 条回复 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26f..6f0a7beb6b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2465,3 +2465,4 @@ animation effects Off 關 field names can't contain a period: {name} 欄位名稱不能包含句點:{name} two cells would both be named {name} 兩個儲存格的名稱都會是 {name} a field named {name} already exists 已有名為 {name} 的欄位 +@plural {n} reply|{n} replies {n} 則回覆 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7d16bc3407..ff52eabcd8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -699,11 +699,22 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Search => ("Search", None), RightPanel::Compare => ("Compare", compare.as_ref().filter(|c| c.new == id).map(|c| c.result.changes.len())), }; + // Comments count like Acrobat (no replies); the replies go in small text below. + let replies = if panel == RightPanel::Comments { crate::comments_panel::reply_count(info) } else { 0 }; ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5))); - if let Some(c) = count { - ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint)); - } + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5))); + if let Some(c) = count { + ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint)); + } + }); + if replies > 0 { + let text = crate::i18n::trn(crate::i18n::current(), replies as u64, "{n} reply", "{n} replies"); + ui.label(egui::RichText::new(text).font(theme::regular(11.0)).color(t.text_faint)); + } + }); if panel == RightPanel::Pages && view.selected.len() > 1 { let n = view.selected.len().to_string(); let picked = crate::i18n::fmt(tl!("{n} pages selected"), &[("n", &n)]); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index ea22c98a04..8abb4720bd 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1177,10 +1177,34 @@ fn replies_follow_irt_references_when_names_repeat_on_every_page() { assert!(reply.rect().top() > h.get_by_label("Second page note").rect().top(), "the reply sits under page 2's comment"); assert!(h.get_by_label("Page 2").rect().top() < reply.rect().top(), "not under page 1's comment, which shares the /NM"); h.get_by_label("Answer on page two"); - // The header counts the two comments, not their replies (Acrobat says 2, not 4). + // The header counts the two comments, not their replies (Acrobat says 2, not 4), and gives + // the replies in small text under it. assert_eq!(h.query_all_by_label("4").count(), 0); + h.get_by_label("2 replies"); } +#[test] +fn the_header_names_no_replies_when_there_are_none() { + let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("cover".into()); + }); + h.get_by_label("Cover note"); + h.get_by_label("2 replies"); // The header counts the document, not the search. + let h = harness_with_bytes(SINGLE_COMMENT.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + h.get_by_label("Only note"); + assert_eq!(h.query_all_by_label_contains("repl").count(), 0, "no \"0 replies\" line"); +} + +// Contributor-original PDF: one comment, no replies. +const SINGLE_COMMENT: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (only) /T (Alpha) /Contents (Only note) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + #[test] fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() { let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { From 21814f60f58c98063f747e4597cf1a97c667bd07 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 13:42:06 +0200 Subject: [PATCH 15/43] fix(comments): show stray and nested replies, leave Replace Text out of the count Follow-up to threading by /IRT reference, from review: - An /IRT that reaches no listed comment (the annotation itself, a loop, a pop-up, an object no page lists) left the annotation a reply shown nowhere, though still counted. `link_replies` now settles the links in both readers (inspect on open, the engine's list after an edit): a reply keeps its parent only when the chain reaches a comment within 64 steps, and anything else stays a comment of its own. - A reply to a reply was never shown in the Comments panel (only comment_list followed the chain, #806), but the new header line counted it. The panel now files every text reply under the comment its thread starts from (`thread_tops`); a status or checkmark stays with the annotation it was set on. - A Replace Text caret (/RT /Group) is grouped with its strikeout, not a reply to it, so the header no longer counts it (new `grouped` field on both readers). - The engine summary and comment_list gather replies once instead of rescanning every annotation per reply (quadratic on large reviews). - Inspect takes the first listing in page order instead of a min over an unordered map. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/annot/src/lib.rs | 3 ++ crates/automation/src/comments.rs | 28 +++++++------- crates/engine/src/lib.rs | 23 ++++++++--- crates/engine/src/tests.rs | 11 ++++-- crates/render/src/inspect.rs | 58 ++++++++++++++++++++++++++-- crates/render/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/comments_panel.rs | 27 +++++++------ crates/ui-egui/tests/it/comments.rs | 46 ++++++++++++++++++++-- crates/ui-egui/tests/perf.rs | 2 +- docs/roadmap.md | 2 +- 12 files changed, 158 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 95be72290d..fe208d3caf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,7 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. -- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7b3446cfcf..6d910194e3 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2256,6 +2256,8 @@ pub struct Summary { pub in_reply_to: Option, /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. + pub grouped: bool, pub rect: [f32; 4], pub color: Option<[f32; 3]>, pub state: Option, @@ -2351,6 +2353,7 @@ pub fn summaries(doc: &Document) -> Vec { name: text_value(doc, d, b"NM"), in_reply_to, parent, + grouped: d.name(b"RT") == Some(b"Group"), rect, color, state: text_value(doc, d, b"State"), diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index bd583aeaed..538b45e334 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,6 +3,8 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. +use std::collections::HashMap; + use pdfcraft_engine::{ Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, }; @@ -198,26 +200,22 @@ impl Automation { } v }; + // Every reply under the comment its thread starts from, at any depth, in document order: + // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would + // drop it. Parents are found by reference, as `/NM` need not be unique. + let tops = pdfcraft_render::thread_tops(&all); + let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for r in &all { + if let Some(&top) = tops.get(&(r.page, r.index)) { + threads.entry(top).or_default().push(r); + } + } let roots: Vec = all .iter() .filter(|c| !c.is_reply() && only.is_none_or(|p| c.page == p)) .map(|c| { let mut v = view(c); - // Every reply under this comment, at any depth, in document order. A reply to a - // reply keeps its `/IRT` on the direct parent, so following one level would drop it. - // Parents are found by reference: `/NM` need not be unique. - let descends_from = |r: &Annotation, root: &Annotation| -> bool { - let mut parent = r.parent; - for _ in 0..64 { - let Some(at) = parent else { return false }; - if at == (root.page, root.index) { - return true; - } - parent = all.iter().find(|x| (x.page, x.index) == at).and_then(|x| x.parent); - } - false - }; - let thread: Vec<&Annotation> = all.iter().filter(|r| r.is_reply() && descends_from(r, c)).collect(); + let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()); v["status"] = json!(status); v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cd9ec5b855..e64bbcb612 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2890,7 +2890,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_annot::summaries(doc) + let mut list: Vec = pdfcraft_annot::summaries(doc) .into_iter() .map(|s| pdfcraft_render::Annotation { page: s.page, @@ -2901,6 +2901,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec Vec &str { pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { use std::fmt::Write; let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| !a.is_reply() && a.state.is_none()).collect(); + // Each reply under the annotation its `/IRT` points at, in document order, gathered once. + type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; + let mut children = Children::new(); + for r in all.iter().filter(|r| r.state.is_none()) { + if let Some(parent) = r.parent { + children.entry(parent).or_default().push(r); + } + } // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &pdfcraft_render::Annotation, depth: usize) { + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { use std::fmt::Write; if depth > 64 { return; } - for r in all.iter().filter(|r| r.state.is_none() && r.replies_to(parent)) { + for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { let indent = " ".repeat(depth + 1); let _ = writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); for line in r.contents.as_deref().unwrap_or("").lines() { let _ = writeln!(out, "{indent}{line}"); } - write_replies(out, all, r, depth + 1); + write_replies(out, children, r, depth + 1); } } let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); @@ -5382,7 +5393,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { let _ = writeln!(out, "{c}"); } - write_replies(&mut out, all, a, 0); + write_replies(&mut out, &children, a, 0); out.push('\n'); } out diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 44ac16c345..1d865b0bce 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3420,18 +3420,20 @@ fn comments_lock_take_checkmarks_hide_and_summarize() { } /// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every -/// page `fitz-A0`, which used to file every reply under page 1's comment. Both readers (inspect on -/// open, and the comment list the engine refreshes after an edit) must agree. +/// page `fitz-A0`, which used to file every reply under page 1's comment. An `/IRT` to the +/// annotation itself leaves it a comment of its own. Both readers (inspect on open, and the +/// comment list the engine refreshes after an edit) must agree. #[test] fn replies_thread_by_reference_when_names_repeat_across_pages() { let pdf = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R] >> endobj 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj 5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj 6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj 7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 8 0 R /T (Delta) /Contents (SELF) >> endobj trailer << /Root 1 0 R >> %%EOF"; let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -3440,9 +3442,12 @@ trailer << /Root 1 0 R >> let all = &s.get(id).unwrap().info.annotations; let answer = all.iter().find(|a| a.contents.as_deref() == Some("ANSWER")).unwrap(); assert_eq!(answer.parent, Some((1, 0)), "{answer:?}"); + let looped = all.iter().find(|a| a.contents.as_deref() == Some("SELF")).unwrap(); + assert!(!looped.is_reply(), "{looped:?}"); let text = comment_summary("fitz.pdf", all, SummarySort::Page); let (cover, second) = text.split_once("SECOND").unwrap(); assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}"); + assert!(cover.contains("Number: 2 Author: Delta"), "the self-reply is a comment of its own: {text}"); assert_eq!(second.matches(" Author:").count(), replies, "{text}"); }; check(&s, 1); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0490390163..bb22b00a88 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -421,10 +421,14 @@ pub struct Annotation { /// `/NM` of this annotation, and of the one it replies to (`/IRT`). pub name: Option, pub in_reply_to: Option, - /// The page and `/Annots` position of the annotation `/IRT` points at, found by object + /// The page and `/Annots` position of the comment `/IRT` points at, found by object /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF - /// names the first comment on every page `fitz-A0`. + /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed + /// comment ([`link_replies`]). pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its + /// strikeout) rather than a reply to it. + pub grouped: bool, /// Rect in PDF user space: [x0, y0, x1, y1]. pub rect: [f32; 4], pub color: Option<[f32; 3]>, @@ -452,9 +456,9 @@ impl Annotation { matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) } - /// A reply (or status reply) to another annotation rather than a comment of its own. + /// A reply (or status reply) to another comment rather than a comment of its own. pub fn is_reply(&self) -> bool { - self.in_reply_to.is_some() || self.parent.is_some() + self.parent.is_some() } /// Whether this annotation's `/IRT` points at `other`. @@ -463,6 +467,50 @@ impl Annotation { } } +/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when +/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted +/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays +/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list +/// after an edit) run this. +pub fn link_replies(annotations: &mut [Annotation]) { + let at: HashMap<(usize, usize), usize> = + annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); + let links: Vec> = + annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); + for (i, a) in annotations.iter_mut().enumerate() { + let mut up = links.get(i).copied().flatten(); + let mut settled = false; + for _ in 0..64 { + let Some(j) = up else { break }; + up = links.get(j).copied().flatten(); + if up.is_none() { + settled = true; + break; + } + } + if !settled { + a.parent = None; + } + } +} + +/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a +/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. +pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { + let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); + parents + .iter() + .map(|(&reply, &first)| { + let mut top = first; + for _ in 0..64 { + let Some(&up) = parents.get(&top) else { break }; + top = up; + } + (reply, top) + }) + .collect() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FieldKind { Text, @@ -1370,6 +1418,7 @@ impl<'a> Inspector<'a> { name: self.text(d, b"NM"), in_reply_to, parent, + grouped: self.name(d, b"RT").as_deref() == Some("Group"), rect, color, index, @@ -1388,6 +1437,7 @@ impl<'a> Inspector<'a> { info.annotations.push(annotation); } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); + link_replies(&mut info.annotations); } fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cc1e4af2e3..954823ef09 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -18,7 +18,7 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, pretty_date, + PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0c4104fe99..c458857063 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5738,6 +5738,7 @@ mod tests { name: None, in_reply_to: None, parent: None, + grouped: false, rect: [0.0; 4], color: None, index: 0, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index ede5522fa5..dc157b30d9 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -1,8 +1,9 @@ //! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`). //! -//! Header "Comments N" with search; an "Add a comment" box that posts a sticky note on the -//! current page; cards grouped by page. Selecting a card selects the comment on the page and -//! opens its reply box and "…" menu (Edit, Set status, Delete). Status replies show as a badge. +//! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; +//! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. +//! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, +//! Set status, Delete). Status replies show as a badge. use std::collections::HashMap; @@ -93,9 +94,10 @@ pub fn count(info: &DocInfo) -> usize { info.annotations.iter().filter(|a| !a.is_reply() && a.state.is_none()).count() } -/// The replies under the header's count (not status changes or checkmarks). +/// The replies under the header's count, at any depth: not status changes, checkmarks or a +/// Replace Text caret (grouped with its strikeout, not a reply to it). pub fn reply_count(info: &DocInfo) -> usize { - info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none()).count() + info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none() && !a.grouped).count() } fn matches(a: &Annotation, q: &str) -> bool { @@ -166,12 +168,14 @@ pub(crate) fn show( let mut replies: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); let mut page_counts: HashMap = HashMap::new(); // Replies find their parent by reference (`/NM` need not be unique) and keep source order. - // Page badges count roots before filtering. + // A reply at any depth joins the thread of the comment it starts from, as comment_list lists + // it; a status or checkmark stays with the annotation it was set on. Page badges count roots + // before filtering. + let tops = pdfcraft_render::thread_tops(&info.annotations); for a in &info.annotations { - if a.is_reply() { - if let Some(parent) = a.parent { - replies.entry(parent).or_default().push(a); - } + if let Some(parent) = a.parent { + let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent }; + replies.entry(key).or_default().push(a); } else { let count = page_counts.entry(a.page).or_default(); *count = count.saturating_add(1); @@ -514,8 +518,7 @@ fn card( edit } -/// A 26 pt round badge: a type glyph (comments) or initials (replies). -/// A round avatar; returns where it was drawn. +/// A 26 pt round badge: a type glyph (comments) or initials (replies). Returns where it was drawn. fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) -> egui::Rect { let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); let bg = if t.dark() { Color32::from_gray(0x55) } else { Color32::from_gray(0xD8) }; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 8abb4720bd..13e728c5ed 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1086,8 +1086,8 @@ fn the_panel_filters_and_sorts() { } // Contributor-original PDF. Two roots deliberately share /NM; the replies (on another page, -// with dates opposite to source order) point at the first by reference. An unnamed root has -// no thread. +// with dates opposite to source order) point at the first by reference. Nothing replies to the +// unnamed root. const REVIEW_FIXTURE: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj @@ -1153,7 +1153,7 @@ fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() { h.state_mut().views[0].comments.hidden_checks.clear(); h.run_steps(2); h.get_by_label("Unnamed root"); - assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "an unnamed root has no replies"); + assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root"); } // Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named @@ -1205,6 +1205,46 @@ const SINGLE_COMMENT: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; +// Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply, +// and a Replace Text strikeout with its grouped caret. Page 2: an `/IRT` to the annotation +// itself, to an annotation no page lists (object 11) and to a pop-up. +const THREAD_EDGES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 6 0 R /T (Gamma) /Contents (Reply to the reply) >> endobj +8 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [60 100 160 112] /IT /StrikeOutTextEdit /T (Alpha) /Contents (Struck words) >> endobj +9 0 obj << /Type /Annot /Subtype /Caret /Rect [158 96 166 104] /IRT 8 0 R /RT /Group /T (Alpha) /Contents (Replacement words) >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 10 0 R /T (Delta) /Contents (Points at itself) >> endobj +11 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /T (Nobody) /Contents (Listed on no page) >> endobj +12 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /IRT 11 0 R /T (Delta) /Contents (Points at an unlisted note) >> endobj +13 0 obj << /Type /Annot /Subtype /Popup /Rect [120 100 280 180] >> endobj +14 0 obj << /Type /Annot /Subtype /Text /Rect [20 80 40 100] /IRT 13 0 R /T (Delta) /Contents (Points at a pop-up) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread() { + let h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + // An `/IRT` that reaches no listed comment leaves its annotation a comment of its own, + // rather than a reply shown nowhere. + for orphan in ["Points at itself", "Points at an unlisted note", "Points at a pop-up"] { + assert!(h.get_by_label(orphan).rect().top() > h.get_by_label("Page 2").rect().top(), "{orphan}"); + } + // A reply to a reply joins the thread of the comment it starts from, also when that comment + // has no /NM. + let (parent, direct, deeper) = (h.get_by_label("Unnamed parent"), h.get_by_label("Direct reply"), h.get_by_label("Reply to the reply")); + assert!(parent.rect().top() < direct.rect().top() && direct.rect().top() < deeper.rect().top()); + assert!(deeper.rect().top() < h.get_by_label("Struck words").rect().top(), "under its own thread"); + // Comments: the unnamed parent, the strikeout and the three on page 2. Replies: the two + // replies, not the Replace Text caret grouped with its strikeout. + h.get_by_label("5"); + h.get_by_label("2 replies"); +} + #[test] fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() { let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index 319edeffc8..b2ccc081c7 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -94,7 +94,7 @@ fn pdf(objs: Vec) -> Vec { out } -/// Named roots exercise thread lookups even when none of the comments has replies. +/// Named roots, none with replies: the panel still looks up every root's thread. fn named_comments(n: usize) -> Vec { let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); let mut objs = vec![ diff --git a/docs/roadmap.md b/docs/roadmap.md index bb542aefc7..cddf47679b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -168,7 +168,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: the Comments panel groups replies by name and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | From a629835fec101d8eba17d90455840945f59cc00e Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 13:42:17 +0200 Subject: [PATCH 16/43] fix(automation): refuse a comment id that several comments share The comment tools resolved `id` (the comment's /NM, as comment_list reports it) to the first annotation with that name. /NM need not be unique: in a PyMuPDF file every page's first comment is `fitz-A0`, so comment_reply, comment_set_status and the rest acted on page 1's comment whatever page the caller meant. A shared id is now refused with a message pointing at `page` + `index`, which always name one comment. A new end-to-end test also checks that comment_list threads such a file by reference. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/automation/src/comments.rs | 9 +++-- crates/automation/src/tools.rs | 5 ++- crates/automation/tests/it/automation.rs | 43 ++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fe208d3caf..20613dc466 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,7 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. -- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 538b45e334..87e6ae4f9b 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -158,8 +158,13 @@ impl Automation { fn comment_target(&self, a: &Args) -> Result<(usize, usize)> { let all = self.comments(a)?; if let Some(id) = a.opt_str("id")? { - let c = - all.iter().find(|c| c.name.as_deref() == Some(id)).ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?; + // `/NM` need not be unique (PyMuPDF names the first comment on every page `fitz-A0`): + // the first match could be another page's comment, so a shared id is refused. + let mut named = all.iter().filter(|c| c.name.as_deref() == Some(id)); + let c = named.next().ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?; + if named.next().is_some() { + return Err(failed(format!("several comments have the id {id:?}: pass page and index instead (see comment_list)"))); + } return Ok((c.page, c.index)); } let (Some(page), Some(index)) = (a.opt_int("page")?, a.opt_int("index")?) else { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..9af4eb5b7e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -60,7 +60,10 @@ fn color() -> Value { fn comment_ref(mut extra: Value) -> Value { if let Some(props) = extra.as_object_mut() { props.insert("doc".into(), doc()); - props.insert("id".into(), json!({ "type": "string", "description": "The comment's id (from comment_list)." })); + props.insert( + "id".into(), + json!({ "type": "string", "description": "The comment's id (from comment_list). An id several comments share is refused: pass page and index." }), + ); props.insert("page".into(), json!({ "type": "integer", "minimum": 1, "description": "With `index`, instead of `id`." })); props.insert( "index".into(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..d9c004b130 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2078,6 +2078,49 @@ fn nested_replies_stay_in_the_listing() { assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}"); } +/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every +/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment. +#[test] +fn replies_thread_by_reference_when_ids_repeat_across_pages() { + let dir = workdir("repeated-ids"); + // Contributor-original PDF, shaped like PyMuPDF output. + std::fs::write( + dir.join("fitz.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "fitz.pdf" }))["doc"].as_u64().unwrap(); + let replies = |a: &mut Automation| -> Vec<(String, Vec)> { + let list = ok(a, "comment_list", json!({ "doc": doc })); + list["comments"] + .as_array() + .unwrap() + .iter() + .map(|c| { + let mut texts: Vec = + c["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str().map(String::from)).collect(); + texts.sort(); + (c["contents"].as_str().unwrap().to_string(), texts) + }) + .collect() + }; + assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string()])]); + let refused = err(&mut a, "comment_reply", json!({ "doc": doc, "id": "fitz-A0", "text": "LATER" })); + assert!(refused.to_string().contains("several comments"), "{refused}"); + ok(&mut a, "comment_reply", json!({ "doc": doc, "page": 2, "index": 1, "text": "LATER" })); + assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string(), "LATER".to_string()])]); +} + #[test] fn protecting_through_tools() { let dir = workdir("protect"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 69c02f04e4..a29da7898b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3560,7 +3560,7 @@ milestone = "M3" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages"] +evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages", "crates/automation/tests/it/automation.rs::replies_thread_by_reference_when_ids_repeat_across_pages"] notes = "cards grouped by page with replies (threaded by the `/IRT` reference, so files whose `/NM` repeats per page, as PyMuPDF writes them, thread correctly; the header counts comments without replies, as Acrobat does), status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" [[feature]] From ecb3f6527e830520e93e763f6184bc34d7a70ad7 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 14:45:23 +0200 Subject: [PATCH 17/43] fix(automation): comment_list reads a status or checkmark from the annotation it was set on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A status or checkmark is a state reply whose /IRT names the annotation it applies to (ISO 32000-2 §12.5.6.2). comment_list gathered state replies at any depth of a thread (since #839 listed nested replies), so a checkmark or status set on a reply was reported as the comment's own, while the Comments panel, comment properties and the page's context menu showed the comment unmarked. comment_list now files them under the annotation they were set on, as the panel does. A UI regression pins the card and the page's menu (it also fails if the menu counts checkmarks at any depth), and an automation one pins comment_list. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/automation/src/comments.rs | 13 ++++++--- crates/automation/tests/it/automation.rs | 29 +++++++++++++++++++ crates/ui-egui/tests/it/comments.rs | 36 ++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 20613dc466..f7b6fb3ff3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,7 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. -- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 87e6ae4f9b..05e2b361ee 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -207,13 +207,18 @@ impl Automation { }; // Every reply under the comment its thread starts from, at any depth, in document order: // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would - // drop it. Parents are found by reference, as `/NM` need not be unique. + // drop it. Parents are found by reference, as `/NM` need not be unique. A status or + // checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the + // Comments panel: one set on a reply is not the comment's. let tops = pdfcraft_render::thread_tops(&all); let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); for r in &all { - if let Some(&top) = tops.get(&(r.page, r.index)) { - threads.entry(top).or_default().push(r); - } + let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; + let key = match r.parent { + Some(parent) if r.state.is_some() => parent, + _ => top, + }; + threads.entry(key).or_default().push(r); } let roots: Vec = all .iter() diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index d9c004b130..3c725de9df 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2121,6 +2121,35 @@ trailer << /Root 1 0 R >> assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string(), "LATER".to_string()])]); } +/// A status or checkmark belongs to the annotation its `/IRT` names: one set on a reply is not +/// its comment's, as the Comments panel shows it. +#[test] +fn a_checkmark_on_a_reply_is_not_its_comments() { + let dir = workdir("reply-checkmark"); + // Contributor-original PDF: a comment, a reply, and a checkmark and a status on the reply. + std::fs::write( + dir.join("checked.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (COMMENT) >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (REPLY) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "checked.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + let c = &list["comments"][0]; + assert_eq!((&c["marked"], &c["status"]), (&json!(false), &Value::Null), "{c}"); + assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "the reply, not its states: {c}"); +} + #[test] fn protecting_through_tools() { let dir = workdir("protect"); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 13e728c5ed..f7b8159b9b 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1245,6 +1245,42 @@ fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread( h.get_by_label("2 replies"); } +// Contributor-original PDF: a comment, a reply, and a checkmark and a status set on the reply. +const CHECKED_REPLY: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (Unchecked comment) >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (Checked reply) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A checkmark or status belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2): one +/// set on a reply doesn't mark the comment, on its card or in the page's menu. +#[test] +fn a_checkmark_on_a_reply_does_not_mark_its_comment() { + let mut h = harness_with_bytes(CHECKED_REPLY.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + click_pt(&mut h, (110.0, 110.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + h.get_by_label("Checked reply"); + h.get_by_label("Mark with checkmark"); // The card's checkmark. + assert_eq!(h.query_all_by_label("Accepted").count(), 0, "no status badge"); + let p = at(&h, 110.0, 110.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Copy text"); // The page's menu is open. + assert_eq!(h.query_all_by_label("Remove checkmark").count(), 0); + assert_eq!(h.query_all_by_label("Mark with checkmark").count(), 2, "the card and the page's menu"); +} + #[test] fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() { let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { From f35ec52fda1f4d9fcef391f613ecb2683a6b2542 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 14:58:19 +0200 Subject: [PATCH 18/43] fix(automation): comment_list reports a reply's own status and checkmark The previous commit filed a status or checkmark under the annotation it was set on, but comment_list only reported states for comments, so one set on a reply dropped out of the listing. Each reply in `replies` now carries its own `status` and `marked`, read the same way as a comment's, and the tool description says so. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/automation/src/comments.rs | 22 +++++++++++++++++----- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 4 +++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f7b6fb3ff3..47f3351b06 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,7 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. -- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 05e2b361ee..0ebf622e9d 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -220,17 +220,29 @@ impl Automation { }; threads.entry(key).or_default().push(r); } + // An annotation's review status and checkmark: the latest of each set on it. + let states = |c: &Annotation, v: &mut Value| { + let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); + v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone())); + v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + }; let roots: Vec = all .iter() .filter(|c| !c.is_reply() && only.is_none_or(|p| c.page == p)) .map(|c| { let mut v = view(c); - let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); - let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()); - v["status"] = json!(status); - v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + states(c, &mut v); v["locked"] = json!(c.locked); - v["replies"] = thread.iter().filter(|r| r.state.is_none()).map(|r| view(r)).collect(); + let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); + v["replies"] = thread + .iter() + .filter(|r| r.state.is_none()) + .map(|r| { + let mut reply = view(r); + states(r, &mut reply); + reply + }) + .collect(); v }) .collect(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9af4eb5b7e..7844d3a4dd 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -681,7 +681,7 @@ pub fn tools() -> Vec { .ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])), t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.") .cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])), - t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") + t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), t( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 3c725de9df..d5a06d3506 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2122,7 +2122,7 @@ trailer << /Root 1 0 R >> } /// A status or checkmark belongs to the annotation its `/IRT` names: one set on a reply is not -/// its comment's, as the Comments panel shows it. +/// its comment's, as the Comments panel shows it, and is reported on the reply. #[test] fn a_checkmark_on_a_reply_is_not_its_comments() { let dir = workdir("reply-checkmark"); @@ -2148,6 +2148,8 @@ trailer << /Root 1 0 R >> let c = &list["comments"][0]; assert_eq!((&c["marked"], &c["status"]), (&json!(false), &Value::Null), "{c}"); assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "the reply, not its states: {c}"); + let reply = &c["replies"][0]; + assert_eq!((&reply["marked"], &reply["status"]), (&json!(true), &json!("Accepted")), "{reply}"); } #[test] From b70d88f235596c98ba5f9a3b471fc2d01371d82c Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 16:15:16 +0200 Subject: [PATCH 19/43] fix(comments): test /IRT to links and widgets; say whose a reply's status is From review: - `link_replies` only finds parents among the annotations both readers list, and neither lists links, form widgets, pop-ups or other non-markup annotations (`is_comment_subtype`), so an /IRT to one of those leaves its annotation a comment of its own. Its doc comment now says so; the UI edge-case test adds an /IRT to a link and to a form field, and the engine test an /IRT to a link (after an edit too). Both fail if a reader lists links as comments. - The Comments panel's grouping comment now says that a status or checkmark set on a reply, at any depth, is that reply's: the card's badge and checkmark leave it out, and comment_list reports it on the reply. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 9 +++++++-- crates/render/src/inspect.rs | 5 +++++ crates/ui-egui/src/comments_panel.rs | 6 ++++-- crates/ui-egui/tests/it/comments.rs | 15 ++++++++++----- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1d865b0bce..c03628e7f9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3421,19 +3421,21 @@ fn comments_lock_take_checkmarks_hide_and_summarize() { /// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every /// page `fitz-A0`, which used to file every reply under page 1's comment. An `/IRT` to the -/// annotation itself leaves it a comment of its own. Both readers (inspect on open, and the +/// annotation itself, or to a link (never listed as a comment), leaves it a comment of its own. Both readers (inspect on open, and the /// comment list the engine refreshes after an edit) must agree. #[test] fn replies_thread_by_reference_when_names_repeat_across_pages() { let pdf = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R 9 0 R 10 0 R] >> endobj 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj 5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj 6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj 7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj 8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 8 0 R /T (Delta) /Contents (SELF) >> endobj +9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (fitz-A2) /IRT 9 0 R /T (Echo) /Contents (LINKED) >> endobj trailer << /Root 1 0 R >> %%EOF"; let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -3444,10 +3446,13 @@ trailer << /Root 1 0 R >> assert_eq!(answer.parent, Some((1, 0)), "{answer:?}"); let looped = all.iter().find(|a| a.contents.as_deref() == Some("SELF")).unwrap(); assert!(!looped.is_reply(), "{looped:?}"); + let linked = all.iter().find(|a| a.contents.as_deref() == Some("LINKED")).unwrap(); + assert!(!linked.is_reply(), "{linked:?}"); let text = comment_summary("fitz.pdf", all, SummarySort::Page); let (cover, second) = text.split_once("SECOND").unwrap(); assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}"); assert!(cover.contains("Number: 2 Author: Delta"), "the self-reply is a comment of its own: {text}"); + assert!(cover.contains("Number: 3 Author: Echo"), "so is the reply to a link: {text}"); assert_eq!(second.matches(" Author:").count(), replies, "{text}"); }; check(&s, 1); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index bb22b00a88..f65a9b6975 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -472,6 +472,11 @@ impl Annotation { /// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays /// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list /// after an edit) run this. +/// +/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other +/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an +/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list +/// has them. pub fn link_replies(annotations: &mut [Annotation]) { let at: HashMap<(usize, usize), usize> = annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index dc157b30d9..260fab6e52 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -169,8 +169,10 @@ pub(crate) fn show( let mut page_counts: HashMap = HashMap::new(); // Replies find their parent by reference (`/NM` need not be unique) and keep source order. // A reply at any depth joins the thread of the comment it starts from, as comment_list lists - // it; a status or checkmark stays with the annotation it was set on. Page badges count roots - // before filtering. + // it. A status or checkmark stays with the annotation it was set on (its `/IRT` names it, + // ISO 32000-2 §12.5.6.2): one set on a reply, at any depth, is that reply's and not the + // comment's, so the card's badge and checkmark leave it out (comment_list reports it on the + // reply). Page badges count roots before filtering. let tops = pdfcraft_render::thread_tops(&info.annotations); for a in &info.annotations { if let Some(parent) = a.parent { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index f7b8159b9b..1b8f289c1c 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1207,12 +1207,12 @@ trailer << /Root 1 0 R >> // Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply, // and a Replace Text strikeout with its grouped caret. Page 2: an `/IRT` to the annotation -// itself, to an annotation no page lists (object 11) and to a pop-up. +// itself, to an annotation no page lists (object 11), to a pop-up, to a link and to a form field. const THREAD_EDGES: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj -4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >> endobj 5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj 6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj 7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 6 0 R /T (Gamma) /Contents (Reply to the reply) >> endobj @@ -1223,6 +1223,10 @@ const THREAD_EDGES: &[u8] = b"%PDF-1.7 12 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /IRT 11 0 R /T (Delta) /Contents (Points at an unlisted note) >> endobj 13 0 obj << /Type /Annot /Subtype /Popup /Rect [120 100 280 180] >> endobj 14 0 obj << /Type /Annot /Subtype /Text /Rect [20 80 40 100] /IRT 13 0 R /T (Delta) /Contents (Points at a pop-up) >> endobj +15 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj +16 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (field) /Rect [200 60 260 80] >> endobj +17 0 obj << /Type /Annot /Subtype /Text /Rect [20 50 40 70] /IRT 15 0 R /T (Delta) /Contents (Points at a link) >> endobj +18 0 obj << /Type /Annot /Subtype /Text /Rect [20 20 40 40] /IRT 16 0 R /T (Delta) /Contents (Points at a form field) >> endobj trailer << /Root 1 0 R >> %%EOF"; @@ -1231,7 +1235,8 @@ fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread( let h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("panel", "comments").unwrap()); // An `/IRT` that reaches no listed comment leaves its annotation a comment of its own, // rather than a reply shown nowhere. - for orphan in ["Points at itself", "Points at an unlisted note", "Points at a pop-up"] { + // Links, form widgets and pop-ups are never parents: neither reader lists them as comments. + for orphan in ["Points at itself", "Points at an unlisted note", "Points at a pop-up", "Points at a link", "Points at a form field"] { assert!(h.get_by_label(orphan).rect().top() > h.get_by_label("Page 2").rect().top(), "{orphan}"); } // A reply to a reply joins the thread of the comment it starts from, also when that comment @@ -1239,9 +1244,9 @@ fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread( let (parent, direct, deeper) = (h.get_by_label("Unnamed parent"), h.get_by_label("Direct reply"), h.get_by_label("Reply to the reply")); assert!(parent.rect().top() < direct.rect().top() && direct.rect().top() < deeper.rect().top()); assert!(deeper.rect().top() < h.get_by_label("Struck words").rect().top(), "under its own thread"); - // Comments: the unnamed parent, the strikeout and the three on page 2. Replies: the two + // Comments: the unnamed parent, the strikeout and the five on page 2. Replies: the two // replies, not the Replace Text caret grouped with its strikeout. - h.get_by_label("5"); + h.get_by_label("7"); h.get_by_label("2 replies"); } From 931b9a599a7132e634f905682c881e82fcf092da Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 16:46:06 +0200 Subject: [PATCH 20/43] fix(comments): a status whose comment can't be found is listed nowhere `link_replies` makes an annotation whose /IRT leads nowhere a comment of its own. For a status or checkmark that meant a card in the Comments panel (and an invisible hit area on the page, and a root in comment_list) that the header's count left out; before threading by reference, most of these were hidden. The realistic case is a comment deleted from another page than its status replies. `Annotation::is_comment` (not a reply, not a status or checkmark) is now the one test for "listed as a comment": the panel's cards, page badges, filter menus and count, the page's hit-testing, comment_list's roots and the summary all use it. A Replace Text caret still shows in its strikeout's card: it holds the replacement text, and the count already treats the pair as one comment. From CodeRabbit review on #4061. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/automation/src/comments.rs | 2 +- crates/automation/tests/it/automation.rs | 9 ++++++--- crates/engine/src/lib.rs | 2 +- crates/render/src/inspect.rs | 7 +++++++ crates/ui-egui/src/comments.rs | 2 +- crates/ui-egui/src/comments_panel.rs | 12 ++++++------ crates/ui-egui/tests/it/comments.rs | 18 ++++++++++++++---- 8 files changed, 37 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 47f3351b06..65e509f116 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,7 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. -- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 0ebf622e9d..876c0ca2fe 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -228,7 +228,7 @@ impl Automation { }; let roots: Vec = all .iter() - .filter(|c| !c.is_reply() && only.is_none_or(|p| c.page == p)) + .filter(|c| c.is_comment() && only.is_none_or(|p| c.page == p)) .map(|c| { let mut v = view(c); states(c, &mut v); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index d5a06d3506..cffcab4d38 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2122,21 +2122,24 @@ trailer << /Root 1 0 R >> } /// A status or checkmark belongs to the annotation its `/IRT` names: one set on a reply is not -/// its comment's, as the Comments panel shows it, and is reported on the reply. +/// its comment's, as the Comments panel shows it, and is reported on the reply. One whose +/// annotation can't be found is not a comment of its own. #[test] fn a_checkmark_on_a_reply_is_not_its_comments() { let dir = workdir("reply-checkmark"); - // Contributor-original PDF: a comment, a reply, and a checkmark and a status on the reply. + // Contributor-original PDF: a comment, a reply, a checkmark and a status on the reply, and a + // status whose annotation (object 9) doesn't exist. std::fs::write( dir.join("checked.pdf"), b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj 4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (COMMENT) >> endobj 5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (REPLY) >> endobj 6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj 7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [200 20 220 40] /F 30 /IRT 9 0 R /T (Gamma) /StateModel /Review /State /Rejected >> endobj trailer << /Root 1 0 R >> %%EOF", ) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e64bbcb612..42238e7754 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5331,7 +5331,7 @@ pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { /// pop-ups; the number is the comment's position on its page. pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { use std::fmt::Write; - let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| !a.is_reply() && a.state.is_none()).collect(); + let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); // Each reply under the annotation its `/IRT` points at, in document order, gathered once. type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; let mut children = Children::new(); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index f65a9b6975..15e10898c2 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -461,6 +461,13 @@ impl Annotation { self.parent.is_some() } + /// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't + /// be found (deleted from another page, or never listed) has nothing to show, so it is listed + /// nowhere: not as a card, on the page or in a count. + pub fn is_comment(&self) -> bool { + !self.is_reply() && self.state.is_none() + } + /// Whether this annotation's `/IRT` points at `other`. pub fn replies_to(&self, other: &Annotation) -> bool { self.parent == Some((other.page, other.index)) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 0ff3219788..86ef9efbfe 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -624,7 +624,7 @@ impl PageCx<'_> { /// Top-level comments on this page, in paint order. fn comments(&self) -> impl Iterator { - self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && !a.is_reply() && a.subtype != "Popup") + self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.is_comment() && a.subtype != "Popup") } /// Where a comment is on screen: one rectangle, or one per marked line for text markup. diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 260fab6e52..2fe11e98fa 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -91,7 +91,7 @@ pub fn is_marked(thread: &[&Annotation]) -> bool { /// The number shown in the header: comments, not their replies or status changes (as Acrobat /// counts them). pub fn count(info: &DocInfo) -> usize { - info.annotations.iter().filter(|a| !a.is_reply() && a.state.is_none()).count() + info.annotations.iter().filter(|a| a.is_comment()).count() } /// The replies under the header's count, at any depth: not status changes, checkmarks or a @@ -178,7 +178,7 @@ pub(crate) fn show( if let Some(parent) = a.parent { let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent }; replies.entry(key).or_default().push(a); - } else { + } else if a.is_comment() { let count = page_counts.entry(a.page).or_default(); *count = count.saturating_add(1); } @@ -190,7 +190,7 @@ pub(crate) fn show( let mut roots: Vec<&Annotation> = info .annotations .iter() - .filter(|a| !a.is_reply()) + .filter(|a| a.is_comment()) .filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q)))) .filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a))) .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) @@ -550,13 +550,13 @@ fn legible(c: Color32, t: &Tokens) -> Color32 { pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView, hidden: bool) -> Option<&'static str> { let mut command = None; let cv = &mut view.comments; - let mut types: Vec = info.annotations.iter().filter(|a| !a.is_reply()).map(|a| kind_label(a).to_string()).collect(); + let mut types: Vec = info.annotations.iter().filter(|a| a.is_comment()).map(|a| kind_label(a).to_string()).collect(); types.sort(); types.dedup(); - let mut authors: Vec = info.annotations.iter().filter(|a| !a.is_reply()).map(|a| a.author.clone().unwrap_or_default()).collect(); + let mut authors: Vec = info.annotations.iter().filter(|a| a.is_comment()).map(|a| a.author.clone().unwrap_or_default()).collect(); authors.sort(); authors.dedup(); - let mut colors: Vec<(String, Option<[f32; 3]>)> = info.annotations.iter().filter(|a| !a.is_reply()).map(|a| (color_key(a), a.color)).collect(); + let mut colors: Vec<(String, Option<[f32; 3]>)> = info.annotations.iter().filter(|a| a.is_comment()).map(|a| (color_key(a), a.color)).collect(); colors.sort_by(|a, b| a.0.cmp(&b.0)); colors.dedup_by(|a, b| a.0 == b.0); let active = !(cv.hidden_types.is_empty() diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 1b8f289c1c..29dcd0ce7c 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1206,12 +1206,13 @@ trailer << /Root 1 0 R >> %%EOF"; // Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply, -// and a Replace Text strikeout with its grouped caret. Page 2: an `/IRT` to the annotation -// itself, to an annotation no page lists (object 11), to a pop-up, to a link and to a form field. +// a Replace Text strikeout with its grouped caret, and a hidden status whose comment no page +// lists. Page 2: an `/IRT` to the annotation itself, to an annotation no page lists (object 11), +// to a pop-up, to a link and to a form field. const THREAD_EDGES: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 19 0 R] >> endobj 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >> endobj 5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj 6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj @@ -1227,12 +1228,16 @@ const THREAD_EDGES: &[u8] = b"%PDF-1.7 16 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (field) /Rect [200 60 260 80] >> endobj 17 0 obj << /Type /Annot /Subtype /Text /Rect [20 50 40 70] /IRT 15 0 R /T (Delta) /Contents (Points at a link) >> endobj 18 0 obj << /Type /Annot /Subtype /Text /Rect [20 20 40 40] /IRT 16 0 R /T (Delta) /Contents (Points at a form field) >> endobj +19 0 obj << /Type /Annot /Subtype /Text /Rect [240 20 260 40] /F 30 /IRT 11 0 R /T (Delta) /StateModel /Review /State /Accepted /Contents (Stray status) >> endobj trailer << /Root 1 0 R >> %%EOF"; #[test] fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread() { - let h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); // An `/IRT` that reaches no listed comment leaves its annotation a comment of its own, // rather than a reply shown nowhere. // Links, form widgets and pop-ups are never parents: neither reader lists them as comments. @@ -1248,6 +1253,11 @@ fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread( // replies, not the Replace Text caret grouped with its strikeout. h.get_by_label("7"); h.get_by_label("2 replies"); + // A status whose comment no page lists is not a comment: no card, and nothing to pick on the + // page where it sits. + assert_eq!(h.query_all_by_label("Stray status").count(), 0); + click_pt(&mut h, (250.0, 30.0)); + assert_eq!(h.state().views[0].comments.selected, None); } // Contributor-original PDF: a comment, a reply, and a checkmark and a status set on the reply. From 7c3dfe0a2aabbbfc9dcf4cac1f2bbf158f7d89fe Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 23 Oct 2026 09:41:21 +0200 Subject: [PATCH 21/43] test(render): a reply finds its comment through a reference chain and a shared /Annots array Rebasing onto main met its annotation listing fixes (c82dc91a, dfde9078): inspect now lists each annotation once, at the first page and position that lists it, told apart by the object its reference chain ends at, and reads a page-shared /Annots array under its first page only. The rebase collects reply slots in that same pass and matches /IRT by the object it resolves to, so a reply finds its comment wherever the listing keeps it. The test fails with the slots keyed by the raw /Annots reference (as before the rebase) and with /IRT matched by its raw reference. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/inspect.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 15e10898c2..0840e8938e 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -2940,6 +2940,39 @@ trailer << /Root 1 0 R >> } } + /// A reply finds its comment where the listing keeps it: on the first page of a page-shared + /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. + #[test] + fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() { + let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ + 5 0 obj 6 0 R endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\ + 7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\ + 9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 0 R >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF"; + let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let mut saved = Vec::new(); + doc.save_to(&mut saved).expect("saves"); + let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + for reader in [&doc as &dyn Structure, &lazy] { + let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; + Inspector::new(reader).fill(&mut info); + let notes = &info.annotations; + assert_eq!(notes.len(), 3, "{notes:?}"); + let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed"); + assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false)); + for reply in ["to the comment", "through the chain"] { + let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); + assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}"); + } + } + } + /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw /// length, so a string many entries share is converted only a few times. #[test] From d9bc4b17a526e922575fc87a697f3d4475e15678 Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 23 Oct 2026 11:35:10 +0200 Subject: [PATCH 22/43] fix(redact): clearing marks leaves the page as it was and takes replies with them Clear all wrote `/Annots []` on a page that had no annotations and replaced an indirect `/Annots` array with an inline copy. It also left replies to a mark in place, so their `/IRT` kept the cleared mark in the document (and in the comment list, as a reply to nothing). `clear_marks` now writes the list through the annot crate's `set_annots` (made public), as deleting a comment does, and removes each mark's replies and pop-ups with it. The "what goes with a deleted comment" walk moves from `delete_annotation` into `with_replies_and_popups`, which both use; it visits each annotation once instead of sweeping the list until nothing changes. Co-Authored-By: Claude Opus 5.5 --- crates/annot/src/lib.rs | 62 ++++++++++++++++++++------------------ crates/annot/src/links.rs | 2 +- crates/redact/src/lib.rs | 27 ++++++++--------- crates/redact/src/tests.rs | 28 +++++++++++++++++ 4 files changed, 73 insertions(+), 46 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 720d39cd80..d05c2ca412 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -14,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; pub mod appearance; @@ -774,8 +776,9 @@ thread_local! { static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. -fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), AnnotError> { +/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty +/// list removes a direct `/Annots`. +pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); match existing { Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), @@ -1647,36 +1650,35 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - let mut doomed: Vec = target.as_ref().into_iter().collect(); - // Replies (`/IRT`) and pop-ups (`/Parent`) of anything doomed, to a fixpoint. - loop { - let before = doomed.len(); - for e in &list { - let Some(r) = e.as_ref() else { continue }; - if doomed.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| doomed.contains(&t)); - if points_at(b"IRT") || points_at(b"Parent") { - doomed.push(r); - } - } - for r in doomed.clone() { - if let Some(pop) = doc.get(r).as_dict().and_then(|d| d.reference(b"Popup")) - && !doomed.contains(&pop) - { - doomed.push(pop); - } - } - if doomed.len() == before { - break; - } - } + let doomed = with_replies_and_popups(doc, &list, target.as_ref()); let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !e.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(_, e)| e).collect(); - set_annots(doc, p, kept) + Ok(set_annots(doc, p, kept)?) +} + +/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or +/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their +/// own replies, and the pop-ups going annotations name (`/Popup`). Each annotation is visited +/// once, so a long or cyclic chain costs no more than its length. +pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { + let mut hanging: HashMap> = HashMap::new(); + for r in list.iter().filter_map(Object::as_ref) { + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + hanging.entry(on).or_default().push(r); + } + } + let mut going = HashSet::new(); + let mut todo: Vec = doomed.into_iter().collect(); + while let Some(r) = todo.pop() { + if !going.insert(r) { + continue; + } + todo.extend(hanging.get(&r).into_iter().flatten()); + todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))); + } + going } /// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 53c31d83a6..7cf4328c7c 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -320,7 +320,7 @@ pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), Annot let (p, _) = link_at(doc, page, index)?; let mut list = annots(doc, p); list.remove(index); - set_annots(doc, p, list) + Ok(set_annots(doc, p, list)?) } /// Remove every link on `pages` (all when `None`). Returns how many went. diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 29f23d3c96..f4e22d6b3b 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -699,27 +699,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result { - let all = marks(doc); - let doomed: Vec = all.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); - if doomed.is_empty() { + let targets: HashSet = marks(doc).iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); + if targets.is_empty() { return Err(RedactError::NothingToApply); } + let listed: Vec = pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).collect(); + let doomed = pdfcraft_annot::with_replies_and_popups(doc, &listed, targets.iter().copied()); for p in pdfcraft_model::pages(doc) { let annots = annots_of(doc, &p.dict); - let kept: Vec = annots - .iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - let parent = doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); - !doomed.contains(&r) && !parent.is_some_and(|p| doomed.contains(&p)) - }) - .cloned() - .collect(); + let kept: Vec = annots.iter().filter(|a| !a.as_ref().is_some_and(|r| doomed.contains(&r))).cloned().collect(); + // As deleting a comment does: a page whose marks were all it had loses `/Annots` again, + // and an indirect array stays the one object the page points to. if kept.len() != annots.len() { - doc.update_dict(p.obj, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?; + pdfcraft_annot::set_annots(doc, p.obj, kept)?; } } - Ok(doomed.len()) + Ok(targets.len()) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b952377105..1424656c90 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1041,6 +1041,34 @@ fn nothing_to_apply_and_clearing_marks() { assert!(content(&doc, 0).contains("(AB) Tj")); } +/// Clearing marks puts a page's annotation list back as it was: a page that had none has no +/// `/Annots` again, replies to the marks included, and one listed through an indirect array keeps +/// pointing at that array. +#[test] +fn clearing_marks_leaves_the_annotation_list_as_it_was() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]); + let page = pdfcraft_model::pages(&doc)[0].obj; + let before = doc.get(page); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + pdfcraft_annot::add_reply(&mut doc, 0, 0, "a reply", "Tester", &Meta::default()).unwrap(); + assert_eq!(clear_marks(&mut doc, None), Ok(1), "one mark"); + assert_eq!(doc.get(page), before, "the page has no /Annots again: the reply went with its mark"); + + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots 4 0 R >>".to_vec(), + b"[5 0 R]".to_vec(), + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]); + let (page, list) = (ObjRef::new(3, 0), ObjRef::new(4, 0)); + let before = (doc.get(page), doc.get(list)); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + assert_eq!(doc.get(list).as_array().map(|a| a.len()), Some(2), "the mark is listed in the shared array"); + clear_marks(&mut doc, None).unwrap(); + assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone"); +} + #[test] fn unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]); From 23f487b7f33ba710a47aaeb0578104fdc7dc3c82 Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 23 Oct 2026 11:35:10 +0200 Subject: [PATCH 23/43] fix(cos,engine): saving leaves out what was added and removed again An incremental save wrote every object added during the session, whether anything still referred to it or not. A redaction mark added and then cleared (or a comment added and deleted) was therefore written into the file as an unreferenced object, with its QuadPoints, overlay text and author. `SaveOptions::drop_unlinked` writes such additions as free entries instead: objects whose numbers the file never used and that nothing the update writes refers to, walked from the trailer and the file's changed objects through additions only. `/Size` and the numbering stay as before, so undo past the save can bring an object back under its number. The option is off by default because the standalone document timestamp is found by scanning, not by reference; `Session::save_bytes` (Save, and the automation save) turns it on. Full saves already left these objects out. Co-Authored-By: Claude Opus 5.5 --- crates/cos/src/writer.rs | 55 +++++++++++++++++++++++++++++++---- crates/cos/tests/it/writer.rs | 48 ++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 3 +- crates/engine/src/tests.rs | 49 +++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index b151399fef..61d14475b4 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -2,17 +2,20 @@ //! //! * `write_incremental` appends the edited objects, a cross-reference section in the same style //! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are -//! never modified, which keeps digital signatures over earlier revisions valid. +//! never modified, which keeps digital signatures over earlier revisions valid. With +//! `SaveOptions::drop_unlinked`, objects added since the file was read that nothing refers to +//! any more are left out (written as free). //! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a //! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer //! entries are copied, including objects reachable only from them. use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::io::Write; +use std::sync::Arc; use crate::CosError; -use crate::document::{Document, ObjectReader}; +use crate::document::{Document, ObjectReader, XrefEntry}; use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; /// Serialize one object. Deterministic: same object → same bytes. @@ -156,11 +159,16 @@ pub struct SaveOptions { /// Full saves: pack non-stream objects into compressed object streams with a cross-reference /// stream (PDF 1.5+, §7.5.7–7.5.8). Off writes a classic table that any reader accepts. pub object_streams: bool, + /// Incremental saves: leave out what was added since the file was read and nothing refers to + /// any more, such as a comment added and deleted again or a redaction mark added and cleared + /// (written as free). Off by default, because a writer may add an object only it finds again + /// by scanning, as a standalone document timestamp is. Full saves always leave these out. + pub drop_unlinked: bool, } impl Default for SaveOptions { fn default() -> Self { - Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true } + Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false } } } @@ -209,8 +217,14 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64; let mut out = Vec::new(); let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use) + let unlinked = if opts.drop_unlinked { unlinked_additions(&doc) } else { HashSet::new() }; for (num, generation, obj) in doc.overlay_entries() { match obj { + // Written as free, so `/Size` still covers every number this document handed out. The + // generation stays: the number was never in use, and an undo can bring it back as is. + Some(_) if unlinked.contains(&num) => { + offsets.insert(num, (0, generation, false)); + } Some(o) => { offsets.insert(num, (base + out.len() as u64, generation, true)); write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out); @@ -387,7 +401,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. -fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result, CosError> { +fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result, CosError> { let o = reader.try_get(r)?; if matches!(*o, Object::Null) || matches!(*o, Object::Stream(_)) != is_stream { return Err(CosError::Syntax { offset: 0, detail: format!("object {} read differently while saving; nothing was written", r.num) }); @@ -439,6 +453,37 @@ fn output_number(index: usize) -> Result { u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() }) } +/// Objects added since the file was read (their numbers are not in use in it) that nothing an +/// incremental update writes refers to: what one edit added and a later one unlinked, such as a +/// deleted comment or a cleared redaction mark. The file never held them, so leaving them out +/// loses nothing, while writing them would keep what the user removed. Only the update is walked: +/// from the trailer and the file's changed objects through additions. An unchanged object of the +/// file is not followed: a reference it holds to such a number already reads as null in the file +/// (a damaged file's dangling reference), and it still does. +fn unlinked_additions(doc: &Document) -> HashSet { + let added = |num: u32| matches!(doc.xref_entry(num), None | Some(XrefEntry::Free { .. })); + let mut pending: HashMap> = + doc.overlay_entries().filter_map(|(num, _, o)| Some((num, o?))).filter(|(num, _)| added(*num)).collect(); + if pending.is_empty() { + return HashSet::new(); + } + let mut stack: Vec = Vec::new(); + doc.trailer().iter().for_each(|(_, v)| collect_refs(v, &mut |r| stack.push(r))); + for (num, _, o) in doc.overlay_entries() { + if let Some(o) = o + && !added(num) + { + collect_refs(&o, &mut |r| stack.push(r)); + } + } + while let Some(r) = stack.pop() { + if let Some(o) = pending.remove(&r.num) { + collect_refs(&o, &mut |r| stack.push(r)); + } + } + pending.into_keys().collect() +} + fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) { match o { Object::Ref(r) => f(*r), diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index ac04be2e94..f71bdac47c 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -228,6 +228,54 @@ fn incremental_save_keeps_a_trailer_only_edit() { assert_qpdf_clean(&saved); } +/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is +/// not written: the file never held it, and writing it would keep what was removed. Additions the +/// update still refers to, from the trailer or a changed object and through other additions, are. +#[test] +fn incremental_save_leaves_out_additions_nothing_refers_to() { + let mut doc = Document::open(Arc::new(classic_table())).unwrap(); + let text = |s: &str| Object::String(pdfcraft_cos::PdfString::literal(s.as_bytes().to_vec())); + let dict = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone())); + Object::Dict(d) + }; + let page_obj = ObjRef::new(3, 0); + let leaf = doc.add(text("kept leaf")); + let kept = doc.add(dict(&[("Leaf", Object::Ref(leaf))])); + doc.update_dict(page_obj, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(kept))).unwrap(); + let by_trailer = doc.add(text("kept by the trailer")); + doc.trailer_mut().set(b"Custom".to_vec(), Object::Ref(by_trailer)); + // Linked from the page and unlinked again, as clearing a mark does. + let dropped_leaf = doc.add(text("dropped leaf")); + let dropped = doc.add(dict(&[("Leaf", Object::Ref(dropped_leaf))])); + doc.update_dict(page_obj, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(dropped)]))).unwrap(); + doc.update_dict(page_obj, |d| { + d.remove(b"Annots"); + }) + .unwrap(); + // Unlinked additions that refer to each other. + let first = doc.add(Object::Null); + let second = doc.add(dict(&[("Next", Object::Ref(first)), ("Note", text("dropped cycle"))])); + doc.set(first, dict(&[("Next", Object::Ref(second))])); + + // Off by default: a writer may add what only it finds again, by scanning. + let kept_all = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(kept_all.windows(12).any(|w| w == b"dropped leaf")); + let saved = write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() }).unwrap(); + let holds = |s: &str| saved.windows(s.len()).any(|w| w == s.as_bytes()); + assert!(holds("kept leaf") && holds("kept by the trailer")); + assert!(!holds("dropped leaf") && !holds("dropped cycle"), "unlinked additions are written"); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.get(leaf).as_string().map(|s| s.bytes.as_slice()), Some(b"kept leaf".as_slice())); + for gone in [dropped, dropped_leaf, first, second] { + assert_eq!(*back.get(gone), Object::Null); + } + assert!(!page(&back).contains(b"Annots")); + assert_qpdf_clean(&saved); +} + #[test] fn qpdf_accepts_object_stream_output() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b97..da31821990 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4310,7 +4310,8 @@ impl Session { if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { return Ok(editor.saved.clone()); } - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; // A restored snapshot can be based on an earlier revision. Append to the last saved // file so that revision stays in the chain. A full rewrite (redaction, new encryption, // a reconstructed file) still writes the current graph on its own. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..70165d4f64 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2490,6 +2490,55 @@ fn redaction_marks_apply_for_good_and_undo() { assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); } +/// Marks removed before they are applied, all at once or one by one, are not saved, nor are +/// replies to them: the file would otherwise record where they were, their overlay text and who +/// made them, and the page is left as it was. +#[test] +fn removed_redaction_marks_are_not_saved() { + for clear in [true, false] { + let (mut s, id) = session_with(1); + let shape = Shape::Redact { + quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], + overlay: "MARKED OVERLAY".into(), + look: Default::default(), + }; + let mark = Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Mark Author".into(), + }); + s.apply(id, mark).unwrap(); + let reply = Edit::ReplyToAnnotation { page: 0, index: 0, text: "REPLY TO THE MARK".into(), author: "Replier".into() }; + s.apply(id, reply).unwrap(); + let removal = if clear { Edit::ClearRedactions } else { Edit::DeleteAnnotation { page: 0, index: 0 } }; + s.apply(id, removal).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_marks(), 0); + let saved = s.save_bytes(id).unwrap(); + let holds = |s: &[u8]| saved.windows(s.len()).any(|w| w == s); + assert!(!holds(b"/Redact") && !holds(b"MARKED OVERLAY") && !holds(b"Mark Author"), "clear: {clear}"); + assert!(!holds(b"REPLY TO THE MARK") && !holds(b"Replier"), "clear: {clear}"); + let mut reopened = Session::new(); + let back = reopened.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(page_texts(&reopened, back), ["Page 1"]); + let page = pdfcraft_model::pages(&reopened.get(back).unwrap().editor.as_ref().unwrap().cos).swap_remove(0); + assert!(!page.dict.contains(b"Annots"), "clear: {clear}"); + + // Undo past that save brings the mark back, under the number the saved file left free. + s.mark_saved(id, saved, None).unwrap(); + s.undo(id).unwrap(); + let again = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let back = reopened.open("again.pdf", None, again.clone(), None).unwrap(); + let d = reopened.get(back).unwrap(); + assert!(d.info.warnings.is_empty() && d.editor.as_ref().unwrap().cos.repair_log().is_empty(), "{:?}", d.info.warnings); + assert_eq!(d.redaction_marks(), 1, "clear: {clear}"); + let marks = pdfcraft_redact::marks(&d.editor.as_ref().unwrap().cos); + assert_eq!(marks.first().map(|m| m.overlay.as_str()), Some("MARKED OVERLAY")); + } +} + #[test] fn oversized_redaction_overlay_preserves_the_document_and_undo() { let (mut s, id) = session_with(1); From 195a2c9bab340d61a84ab14bfd09b116246541fe Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 23 Oct 2026 12:24:39 +0200 Subject: [PATCH 24/43] fix(annot,redact): inline replies and pop-ups go with what they point at `/Annots` may hold an annotation dictionary inline instead of a reference (the standard asks for references, but files do it and PdfCraft accepts them). Deleting a comment or clearing redaction marks only matched referenced entries, so an inline reply (`/IRT`) or pop-up (`/Parent`) of what went stayed behind, pointing at nothing. The pop-up an inline reply or an inline comment names (`/Popup`) stayed too. This predates the shared walk. `goes_with` matches an entry against what is going, inline entries included; `delete_annotation` and `clear_marks` filter with it. The walk follows the pop-up an inline reply names, and deleting an inline comment takes the pop-up it names. Co-Authored-By: Claude Opus 5.5 --- crates/annot/src/lib.rs | 33 ++++++++++++++++++++++-------- crates/annot/src/tests.rs | 41 ++++++++++++++++++++++++++++++++++++++ crates/redact/src/lib.rs | 2 +- crates/redact/src/tests.rs | 9 ++++++++- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index d05c2ca412..b235df7cd0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1650,23 +1650,40 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - let doomed = with_replies_and_popups(doc, &list, target.as_ref()); - let kept: Vec = - list.into_iter().enumerate().filter(|(i, e)| *i != index && !e.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(_, e)| e).collect(); + // An inline comment has no reference to collect; the pop-up it names still goes. + let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); + let doomed = with_replies_and_popups(doc, &list, seed); + let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect(); Ok(set_annots(doc, p, kept)?) } +/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is +/// one of them, or it is written inline (a file can, though the standard asks for references) and +/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference +/// for the walk to collect, so it is matched here. +pub fn goes_with(entry: &Object, going: &HashSet) -> bool { + match entry.as_ref() { + Some(r) => going.contains(&r), + None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), + } +} + /// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or /// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their -/// own replies, and the pop-ups going annotations name (`/Popup`). Each annotation is visited -/// once, so a long or cyclic chain costs no more than its length. +/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no +/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or +/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also +/// matches inline entries. pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { let mut hanging: HashMap> = HashMap::new(); - for r in list.iter().filter_map(Object::as_ref) { - let obj = doc.get(r); + for e in list { + let obj = doc.resolve(e); let Some(d) = obj.as_dict() else { continue }; + // What goes with the annotation this entry hangs off: the entry, or for an inline one the + // pop-up it names. + let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { - hanging.entry(on).or_default().push(r); + hanging.entry(on).or_default().push(goes); } } let mut going = HashSet::new(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 20cc89b106..71cc44e20d 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -229,6 +229,47 @@ fn delete_takes_popup_and_reply_chain() { assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 })); } +/// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for +/// references) goes with the comment it points at, and so does a pop-up an inline reply or an +/// inline comment names. An unrelated inline comment stays until it is deleted itself. +#[test] +fn delete_takes_inline_replies_and_popups() { + let mut doc = fixture(); + let mut popup = |note: &str| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Popup")); + d.set(b"Contents".to_vec(), PdfString::text(note)); + doc.add(Object::Dict(d)) + }; + let (reply_popup, square_popup) = (popup("of the reply"), popup("of the square")); + let inline = |subtype: &str, links: &[(&[u8], ObjRef)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + links.iter().for_each(|(key, r)| d.set(key.to_vec(), Object::Ref(*r))); + Object::Dict(d) + }; + let stamp = ObjRef::new(6, 0); + let entries = vec![ + Object::Ref(stamp), + inline("Text", &[(b"IRT", stamp), (b"Popup", reply_popup)]), + Object::Ref(reply_popup), + inline("Popup", &[(b"Parent", stamp)]), + inline("Square", &[(b"Popup", square_popup)]), + Object::Ref(square_popup), + ]; + doc.set(ObjRef::new(5, 0), Object::Array(entries)); + let subtypes = |doc: &Document| -> Vec { + list(doc, 1).iter().map(|d| String::from_utf8_lossy(d.name(b"Subtype").unwrap()).into_owned() + ":" + &text(d, b"Contents")).collect() + }; + delete_annotation(&mut doc, 1, 0).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(subtypes(&doc), ["Square:", "Popup:of the square"]); + delete_annotation(&mut doc, 1, 0).unwrap(); + assert!(subtypes(&reopen(&doc)).is_empty(), "the inline comment's pop-up went with it"); +} + #[test] fn deleting_the_last_comment_removes_annots() { let mut doc = fixture(); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index f4e22d6b3b..bdf1db873e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -711,7 +711,7 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result = annots.iter().filter(|a| !a.as_ref().is_some_and(|r| doomed.contains(&r))).cloned().collect(); + let kept: Vec = annots.iter().filter(|a| !pdfcraft_annot::goes_with(a, &doomed)).cloned().collect(); // As deleting a comment does: a page whose marks were all it had loses `/Annots` again, // and an indirect array stays the one object the page points to. if kept.len() != annots.len() { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 1424656c90..2efe81475a 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1051,8 +1051,15 @@ fn clearing_marks_leaves_the_annotation_list_as_it_was() { let before = doc.get(page); mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); pdfcraft_annot::add_reply(&mut doc, 0, 0, "a reply", "Tester", &Meta::default()).unwrap(); + // And a reply written inline, as a file can. + let mut inline = Dict::new(); + inline.set(b"Subtype".to_vec(), Object::name("Text")); + inline.set(b"IRT".to_vec(), Object::Ref(marks(&doc)[0].obj)); + let mut listed = annots_of(&doc, doc.get(page).as_dict().unwrap()); + listed.push(Object::Dict(inline)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(listed))).unwrap(); assert_eq!(clear_marks(&mut doc, None), Ok(1), "one mark"); - assert_eq!(doc.get(page), before, "the page has no /Annots again: the reply went with its mark"); + assert_eq!(doc.get(page), before, "the page has no /Annots again: both replies went with their mark"); let mut doc = pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), From ba33257fa3df91fbb7201a8cb2345dd4839cfc36 Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 23 Oct 2026 21:32:46 +0200 Subject: [PATCH 25/43] fix(sign,cos): signing leaves out what was added and removed again The signing saves wrote with default options, so a redaction mark added and cleared in the session (or a comment added and deleted) reached the file through a signature, a timestamped signature, embedded LTV evidence or a document timestamp, with its QuadPoints, overlay text and authors. A signed revision can't be changed afterwards, so nothing could take it out again. `sign`, `sign_with_timestamp` and `dss::embed` now save with `drop_unlinked`: everything they add is reachable (the signature through the field's `/V`, the store from the catalog), and the ByteRange placeholder is found in the written bytes. `timestamp_document` can't: its `/DocTimeStamp` dictionary has no field pointing to it, so the option would drop it and every document timestamp would fail with "ByteRange placeholder not found". It frees the leftovers first with the new `Document::free_unlinked_additions`, which frees exactly what the option leaves out, and then adds the dictionary. Co-Authored-By: Claude Opus 5.5 --- crates/cos/src/document.rs | 11 ++++++ crates/cos/src/writer.rs | 6 ++-- crates/cos/tests/it/writer.rs | 9 +++++ crates/engine/src/tests.rs | 65 +++++++++++++++++++++++---------- crates/sign/src/dss.rs | 4 ++- crates/sign/src/pdf.rs | 11 ++++-- crates/sign/tests/it/pdf.rs | 67 ++++++++++++++++++++++++++++++++++- 7 files changed, 149 insertions(+), 24 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 892c8e04de..5fc8c6212e 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1312,6 +1312,17 @@ impl Document { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); } + /// Free what was added since the file was read and nothing refers to any more: the objects + /// [`SaveOptions::drop_unlinked`](crate::SaveOptions::drop_unlinked) leaves out of an + /// incremental save, freed as it writes them (the generation stays). For a writer about to + /// add an object that nothing refers to either, which that option would leave out too. + pub fn free_unlinked_additions(&mut self) { + for num in crate::writer::unlinked_additions(self) { + let generation = self.generation(num); + self.overlay.insert(num, Slot::Freed(generation)); + } + } + /// Record `current`'s object graph as unsaved edits of this document. /// /// Used when the working snapshot was restored from an earlier revision and the next diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 61d14475b4..627f716495 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -162,7 +162,9 @@ pub struct SaveOptions { /// Incremental saves: leave out what was added since the file was read and nothing refers to /// any more, such as a comment added and deleted again or a redaction mark added and cleared /// (written as free). Off by default, because a writer may add an object only it finds again - /// by scanning, as a standalone document timestamp is. Full saves always leave these out. + /// by scanning, as a standalone document timestamp is; such a writer frees the leftovers + /// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave + /// these out. pub drop_unlinked: bool, } @@ -460,7 +462,7 @@ fn output_number(index: usize) -> Result { /// from the trailer and the file's changed objects through additions. An unchanged object of the /// file is not followed: a reference it holds to such a number already reads as null in the file /// (a damaged file's dangling reference), and it still does. -fn unlinked_additions(doc: &Document) -> HashSet { +pub(crate) fn unlinked_additions(doc: &Document) -> HashSet { let added = |num: u32| matches!(doc.xref_entry(num), None | Some(XrefEntry::Free { .. })); let mut pending: HashMap> = doc.overlay_entries().filter_map(|(num, _, o)| Some((num, o?))).filter(|(num, _)| added(*num)).collect(); diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index f71bdac47c..1b98664255 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -274,6 +274,15 @@ fn incremental_save_leaves_out_additions_nothing_refers_to() { } assert!(!page(&back).contains(b"Annots")); assert_qpdf_clean(&saved); + + // Freed beforehand, they are written the same way without the option, and what is added + // after that, nothing referring to it either (a document timestamp), is kept. + let mut freed = doc.clone(); + freed.free_unlinked_additions(); + assert_eq!(write_incremental(&freed, &SaveOptions::default()).unwrap(), saved, "the same objects, freed the same way"); + freed.add(text("added after")); + let stamped = write_incremental(&freed, &SaveOptions::default()).unwrap(); + assert!(stamped.windows(11).any(|w| w == b"added after") && !stamped.windows(12).any(|w| w == b"dropped leaf")); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 70165d4f64..e4165a4bfb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2490,6 +2490,28 @@ fn redaction_marks_apply_for_good_and_undo() { assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); } +/// What [`mark_with_reply`] writes that must not reach a file once the mark is removed. +const MARK_TRACES: [&str; 5] = ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"]; + +/// A redaction mark on page 1 (the first annotation there) and a reply to it. +fn mark_with_reply(s: &mut Session, id: DocId) { + let shape = Shape::Redact { + quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], + overlay: "MARKED OVERLAY".into(), + look: Default::default(), + }; + let mark = Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Mark Author".into(), + }); + s.apply(id, mark).unwrap(); + let reply = Edit::ReplyToAnnotation { page: 0, index: 0, text: "REPLY TO THE MARK".into(), author: "Replier".into() }; + s.apply(id, reply).unwrap(); +} + /// Marks removed before they are applied, all at once or one by one, are not saved, nor are /// replies to them: the file would otherwise record where they were, their overlay text and who /// made them, and the page is left as it was. @@ -2497,28 +2519,14 @@ fn redaction_marks_apply_for_good_and_undo() { fn removed_redaction_marks_are_not_saved() { for clear in [true, false] { let (mut s, id) = session_with(1); - let shape = Shape::Redact { - quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], - overlay: "MARKED OVERLAY".into(), - look: Default::default(), - }; - let mark = Edit::AddAnnotation(NewAnnotation { - page: 0, - style: Style::default_for(&shape), - shape, - contents: String::new(), - author: "Mark Author".into(), - }); - s.apply(id, mark).unwrap(); - let reply = Edit::ReplyToAnnotation { page: 0, index: 0, text: "REPLY TO THE MARK".into(), author: "Replier".into() }; - s.apply(id, reply).unwrap(); + mark_with_reply(&mut s, id); let removal = if clear { Edit::ClearRedactions } else { Edit::DeleteAnnotation { page: 0, index: 0 } }; s.apply(id, removal).unwrap(); assert_eq!(s.get(id).unwrap().redaction_marks(), 0); let saved = s.save_bytes(id).unwrap(); - let holds = |s: &[u8]| saved.windows(s.len()).any(|w| w == s); - assert!(!holds(b"/Redact") && !holds(b"MARKED OVERLAY") && !holds(b"Mark Author"), "clear: {clear}"); - assert!(!holds(b"REPLY TO THE MARK") && !holds(b"Replier"), "clear: {clear}"); + for trace in MARK_TRACES { + assert!(!saved.windows(trace.len()).any(|w| w == trace.as_bytes()), "clear: {clear}, {trace:?}"); + } let mut reopened = Session::new(); let back = reopened.open("saved.pdf", None, saved.clone(), None).unwrap(); assert_eq!(page_texts(&reopened, back), ["Page 1"]); @@ -2539,6 +2547,27 @@ fn removed_redaction_marks_are_not_saved() { } } +/// Removed marks are not in a signed file either, nor in one with revocation evidence embedded: +/// no later save could take them out of those. +#[test] +fn removed_redaction_marks_are_not_signed() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + mark_with_reply(&mut s, id); + s.apply(id, Edit::ClearRedactions).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + let evidence = sign::dss::Evidence { certs: vec![digital_id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let ltv = s.embed_ltv(id, &evidence).unwrap(); + for (what, out) in [("signed", signed), ("LTV", ltv)] { + for trace in MARK_TRACES { + assert!(!out.windows(trace.len()).any(|w| w == trace.as_bytes()), "{what}: {trace:?}"); + } + } +} + #[test] fn oversized_redaction_overlay_preserves_the_document_and_undo() { let (mut s, id) = session_with(1); diff --git a/crates/sign/src/dss.rs b/crates/sign/src/dss.rs index e095ec7491..0d82c797a7 100644 --- a/crates/sign/src/dss.rs +++ b/crates/sign/src/dss.rs @@ -93,7 +93,9 @@ pub fn embed(doc: &Document, evidence: &Evidence) -> Result, SignError> } let dss_ref = doc.add(Object::Dict(dss)); doc.update_dict(root, |c| c.set(b"DSS".to_vec(), Object::Ref(dss_ref)))?; - Ok(pdfcraft_cos::write_incremental(&doc, &SaveOptions::default())?) + // What the user removed again before this (a cleared redaction mark) stays out of the file, + // as in a save; the store and everything in it are reachable from the catalog. + Ok(pdfcraft_cos::write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() })?) } /// Store one DER blob as a `/Type /Embed` stream. diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index d9669f08ef..19120c248d 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -2163,8 +2163,11 @@ fn sign_inner( let mut apd = Dict::new(); apd.set(b"N".to_vec(), Object::Ref(ap_ref)); doc.update_dict(widget, |w| w.set(b"AP".to_vec(), Object::Dict(apd)))?; - // Write with the placeholders, then patch them. - let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, ..SaveOptions::default() }; + // Write with the placeholders, then patch them. What the user removed again before signing + // (a cleared redaction mark) stays out of the file, as in a save; what signing added is + // reachable (the signature through the field's `/V`), and the placeholders are found in the + // written bytes. + let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, drop_unlinked: true, ..SaveOptions::default() }; let mut out = pdfcraft_cos::write_incremental(&doc, &save)?; let (br_at, gap) = locate(&out, reserve)?; let (start, end) = gap; @@ -2213,6 +2216,10 @@ pub fn timestamp_document(doc: &Document, tsa: &dyn crate::timestamp::TimestampA )); } let mut doc = doc.clone(); + // What the user removed again before this (a cleared redaction mark) stays out of the file, + // as in a save. Nothing refers to the timestamp either, so the leftovers go first and the + // save keeps everything. + doc.free_unlinked_additions(); let mut v = Dict::new(); v.set(b"Type".to_vec(), Object::name("DocTimeStamp")); v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite")); diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 55aa732d78..f1a0218c62 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, write_incremental}; use pdfcraft_sign::{ Certificate, Detail, DigitalId, Modification, Name, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures, @@ -721,6 +721,71 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details); } +/// A redaction mark and a reply to it, added to the page and removed again before signing (what +/// clearing a mark leaves behind), are in none of the signing outputs: a signature, a timestamped +/// one, embedded revocation evidence or a document timestamp. Nothing can take them out of a +/// signed revision afterwards. Each output still validates. +#[test] +fn what_was_removed_before_signing_is_not_signed() { + use pdfcraft_sign::dss::{self, Evidence}; + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let trust = TrustStore { certs: vec![anchor, tsa.id.certificate.clone()] }; + let text = |s: &str| Object::String(PdfString::literal(s.as_bytes().to_vec())); + let dict = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone())); + Object::Dict(d) + }; + let cleared = |bytes: &[u8]| { + let mut doc = open(bytes); + let page = ObjRef::new(3, 0); + let annots = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()).unwrap(); + let mark = doc.add(dict(&[ + ("Type", Object::name("Annot")), + ("Subtype", Object::name("Redact")), + ("Rect", Object::Array([80, 140, 100, 180].map(Object::Int).to_vec())), + ("OverlayText", text("MARKED OVERLAY")), + ("T", text("Mark Author")), + ("P", Object::Ref(page)), + ])); + let reply = doc.add(dict(&[ + ("Type", Object::name("Annot")), + ("Subtype", Object::name("Text")), + ("IRT", Object::Ref(mark)), + ("Contents", text("REPLY TO THE MARK")), + ("T", text("Replier")), + ])); + let mut with = annots.as_array().cloned().unwrap(); + with.extend([Object::Ref(mark), Object::Ref(reply)]); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(with))).unwrap(); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), annots.clone())).unwrap(); + doc + }; + let unsigned = fixture(); + let signed = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap(); + let evidence = Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let outputs = [ + ("sign", &unsigned, pdfcraft_sign::sign(&cleared(&unsigned), &id, &opts()).unwrap()), + ("sign_with_timestamp", &unsigned, pdfcraft_sign::sign_with_timestamp(&cleared(&unsigned), &id, &opts(), &tsa).unwrap()), + ("dss::embed", &signed, dss::embed(&cleared(&signed), &evidence).unwrap()), + ("timestamp_document", &unsigned, pdfcraft_sign::timestamp_document(&cleared(&unsigned), &tsa, "D:20261006120000Z").unwrap()), + ]; + for (what, input, out) in outputs { + assert!(out.starts_with(input), "{what}: an incremental update"); + let holds = |s: &str| out.windows(s.len()).any(|w| w == s.as_bytes()); + for trace in ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"] { + assert!(!holds(trace), "{what} wrote {trace:?}"); + } + let doc = open(&out); + assert!(doc.repair_log().is_empty(), "{what}: {:?}", doc.repair_log()); + let all: Vec<_> = signatures(&doc, &out, &trust).into_iter().filter(|s| s.signed).collect(); + assert_eq!(all.iter().filter(|s| s.doc_timestamp).count(), usize::from(what == "timestamp_document"), "{what}"); + assert!(!all.is_empty() && all.iter().all(|s| s.status == Status::Valid), "{what}: {all:?}"); + } +} + #[test] fn an_embedded_verified_revocation_invalidates_the_signature() { use pdfcraft_sign::dss::{self, Evidence}; From cc4726f6e63cfc1c46e8be99e1335adee32e98c2 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:33:16 +0200 Subject: [PATCH 26/43] fix(ui): one page pointer per frame, so taps work and nothing over a page lets it through The page view had no single reading of the pointer. About twenty page modules each read egui's raw state (hover_pos, latest_pos, interact_pos, press_origin) and tested it against an unclipped page rectangle. That state knows nothing of what is drawn over the pages, and hover_pos is None on the frame a finger or a Windows pen lifts: egui-winit sends PointerGone with every touch release, and winit reports Windows pen input as touch. So a tap placed no stamp, followed no link and ticked no check box. A touch drag of a comment never committed, and the next drag anywhere on the page moved the comment. The Stamp crosshair showed on the command palette, on the quick bar and on the chrome over a page scrolled under it, a link's tip floated over the Unlock prompt, and a double-click on a dialog's backdrop selected the word under it. canvas::document_area now reads one PagePointer per frame from the pages' content widget (step 1 of the page pointer router in the cursor-handling review): - pos: interact_pos, which keeps the position through a touch release, only while egui has the pages under the pointer with nothing over them (contains_pointer, and the top layer there is the pages', as the closed #2626 did) or while the pages hold a press. - origin: where the held press began, also on the frame that brings both the press and its release. This replaces the text layer's raw-event fallback (#2100). - edges: the frame's primary presses and releases on the pages, in order. It reaches the page modules through comments::PageCx and as an argument: the stamp blocks, text layer, page press and link and comment hover in canvas.rs; comments and the signature drag preview; forms and Prepare a form; Fill & Sign and the Sign area; crop, marquee zoom, snapshot, camera and measure; redact and the Link tool; added content, Edit text, images and objects. page_covered, a hand-kept list of what covers the pages that missed the Unlock prompt, is gone. The pages' hover tip is click-through, as a tooltip is, so a quick move onto it does not take the pointer off the link under it. Tests: tests/it/support/input.rs sends pointer input in the frames the platform delivers it in (a touch tap and drag, a click in one frame, a double-click). kittest otherwise runs one frame per event, so the suite never saw a touch release. tests/it/pointer.rs holds the review's probes and #2626's layer tests. With the fix reverted, the tap (stamp, link, check box), touch drag, palette, quick bar, chrome, Unlock tip and dialog double-click tests fail, and the mouse-click and one-frame-click controls pass. The tip test fails with the tip interactable, and each page_pointer unit test fails under a mutation of the part it pins. The probes for later steps stay ignored: the link hand and the I-beam over other tools' targets (step 2), paragraph edges (step 3) and Escape mid-drag (step 4). touch_tap_ticks_a_check_box is ignored until the check box focus fix that follows: since #2223 a tap on a check box counts as a click away from its focus. tests/it/comments.rs: the multiline markup hover test (#3541) hovered a point below the window on a page rotated 270 degrees, and its tip showed only because the hover was tested against the page's whole rectangle. Its harness now zooms to 100 %, so every rotation shows the whole page, and the hover helper asserts the point is on screen. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/camera_ui.rs | 6 +- crates/ui-egui/src/canvas.rs | 93 ++-- crates/ui-egui/src/comments.rs | 27 +- crates/ui-egui/src/content_ui.rs | 33 +- crates/ui-egui/src/crop.rs | 6 +- crates/ui-egui/src/edit_text_ui.rs | 27 +- crates/ui-egui/src/fill_sign.rs | 7 +- crates/ui-egui/src/forms_ui.rs | 20 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/link_ui.rs | 11 +- crates/ui-egui/src/measure_ui.rs | 2 +- crates/ui-egui/src/object_ui.rs | 32 +- crates/ui-egui/src/page_pointer.rs | 207 +++++++++ crates/ui-egui/src/prepare.rs | 23 +- crates/ui-egui/src/redact_ui.rs | 13 +- crates/ui-egui/src/sign_ui.rs | 14 +- crates/ui-egui/src/signature_drag.rs | 4 +- crates/ui-egui/src/zoom_snap.rs | 6 +- crates/ui-egui/tests/it/comments.rs | 7 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/pointer.rs | 531 +++++++++++++++++++++++ crates/ui-egui/tests/it/support/input.rs | 65 +++ crates/ui-egui/tests/it/support/mod.rs | 5 +- 23 files changed, 1009 insertions(+), 132 deletions(-) create mode 100644 crates/ui-egui/src/page_pointer.rs create mode 100644 crates/ui-egui/tests/it/pointer.rs create mode 100644 crates/ui-egui/tests/it/support/input.rs diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index 720ac7c6a7..1cacc4e163 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -5,6 +5,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -58,9 +59,8 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView, allowed: bool) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { + let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a0e80e0aab..16285f8a98 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -3164,15 +3165,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut open_signature = false; let mut hover_text: Option<(Pos2, String)> = None; let mut badge_hover: Option<(Pos2, String)> = None; - // A dialog, save prompt or palette covers the page. Hover tips are drawn above those - // windows, so a comment under the pointer would otherwise float over the dialog. - let page_covered = app.dialog.is_some() - || app.close_request.is_some() - || app.palette_open - || app.password_prompt.is_some() - || app.pending_link.is_some() - || app.pending_remote_link.is_some() - || app.updates.open; let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; let mut canvas_action: Option = None; @@ -3261,7 +3253,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut best_overlap = -1.0f32; let mut current_overlap = -1.0f32; let mut current_height = f32::INFINITY; - let pointer = ui.input(|i| i.pointer.hover_pos()); + // The pointer as the pages see it: nothing over them, or a press they hold. The page + // modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp); + let pointer = ptr.pos; // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page // gone to earlier that still shows whole beside it. @@ -3421,14 +3416,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); if editing_content { let page_tex = view.pages.get(&i).map(|p| p.tex.id()); - view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); + view.move_background.paint(painter, &xf, i, info, &view.content, page_tex, pointer); } if view.show_page_gaps { painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); } // Comments: tools, selection, moving and resizing come before text selection. - let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units); } @@ -3437,7 +3432,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // every viewing mode); then comments; then text selection. if let QuickTool::Stamp(kind) = tool && allowed - && let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) + && let Some(p) = ptr.within(xf.rect) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() { @@ -3461,7 +3456,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if let Some(cs) = custom_stamp.as_ref() && allowed - && let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) + && let Some(p) = ptr.within(xf.rect) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() { @@ -3488,6 +3483,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui, &resp, &xf, + &ptr, i, info, ft, @@ -3511,15 +3507,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, &resp, &xf, i, info, view); + crate::sign_ui::page_input(ui, &resp, &xf, &ptr, i, info, view); } if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, &resp, &xf, i, view); + crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view); } if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(ui, &resp, &xf, i, view, can_modify); + crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify); } - if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) { + if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) { view.current = i; open_boxes = true; } @@ -3531,7 +3527,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // While a table is being set up, nothing else is drawn or moved. let allowed = can_modify && table_preview.is_none() && open_table.is_none(); - let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view); + let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view); field_props |= o.properties; field_placed |= o.placed; open_table = open_table.or(o.table); @@ -3539,10 +3535,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { use crate::forms_ui::FieldPointer; let field = if tool == QuickTool::Select - && !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view)) + && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) && !comments::select_grabs(ui, &pcx, view) { - crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view) + crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) } else { FieldPointer::Away }; @@ -3556,11 +3552,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let (vx, vy) = xf.screen_to_view(p); text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) }; - crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view) + crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view) }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view); + let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); let on_objects = - !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view); + !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view); let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { let generation = doc.edit_generation(); match view.edit_lines.get(&i) { @@ -3592,6 +3588,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui, &resp, &xf, + &ptr, i, info, &added, @@ -3620,11 +3617,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // Editable text takes precedence over background/overlapping artwork. Both // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); + let text_used = + crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); let image_used = crate::edit_text_ui::image_input( ui, &resp, &xf, + &ptr, i, info, &images, @@ -3634,7 +3633,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ); !others_own_pointer && (text_used || image_used) }; - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view); if tool == QuickTool::Select && resp.secondary_clicked() && can_modify @@ -3712,23 +3711,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else if over_text && cursor_free { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } - let press_here = ui - .input(|inp| { - inp.pointer.press_origin().or_else(|| { - // A press and release can arrive in one pass; egui has already - // cleared press_origin, but the press still starts multi-click selection. - inp.events.iter().find_map(|event| match event { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } - if pos.is_finite() => - { - Some(*pos) - } - _ => None, - }) - }) - }) - .is_some_and(|o| r.contains(o)); - let pressed = ui.input(|inp| inp.pointer.primary_pressed()); + // The pages' press, kept when its release came in the same frame (#2100): it + // still starts multi-click selection. + let press_here = ptr.origin.is_some_and(|o| r.contains(o)); + let pressed = ptr.pressed().is_some(); if pressed && press_here && !over_link { let now = ui.input(|inp| inp.time); // The same limits egui uses to count clicks on release. @@ -3753,7 +3739,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // re-drag over text whose bar Copy closed would keep the bar hidden. view.actions_dismissed = None; view.selection_drag = true; - let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); if view.clicks < 2 { view.selection = if column { @@ -3843,11 +3829,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::redact_ui::after_text(&resp, i, info, view); } if area_tool { - crate::redact_ui::paint(ui, painter, i, view); + crate::redact_ui::paint(painter, i, view, pointer); } comments::paint_page(ui, painter, &pcx, view); if editing_content { - crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view); + crate::content_ui::paint_page(painter, &xf, i, info, &added, view, pointer); } if tool == QuickTool::EditText { crate::object_ui::paint(painter, &xf, i, info, &view.objects); @@ -3856,10 +3842,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::camera_ui::paint(painter, &xf, i, view); } if tool == QuickTool::Link { - crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view); + crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer); } if preparing { - crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); + crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer); if let Some(preview) = &table_preview { crate::field_table_ui::paint_preview(painter, &xf, i, preview); } @@ -3870,7 +3856,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } else { - crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view); + crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer); } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a @@ -3899,8 +3885,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. - if !page_covered - && !matches!(tool, QuickTool::Fill(_)) + if !matches!(tool, QuickTool::Fill(_)) && let Some(p) = pointer { for l in info.links.iter().filter(|l| l.page == i) { @@ -4022,7 +4007,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && navigation_enabled && unobstructed && !single_paging - && let Some(p) = ui.input(|i| i.pointer.latest_pos()) + && let Some(p) = ptr.pos { let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); if dy != 0.0 { @@ -4307,7 +4292,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.frame_visible = visible_now.iter().copied().collect(); if let Some((pos, text)) = hover_text { - egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { + // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. + let tip = egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).interactable(false); + tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_max_width(320.0); ui.label(text); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index b5c016750a..4215bed8c4 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -17,6 +17,7 @@ use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewS use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{QuickTool, icons}; @@ -594,6 +595,8 @@ pub enum SortBy { pub(crate) struct PageCx<'a> { pub page: usize, pub xf: &'a PageXform, + /// The pointer as the pages see it this frame. + pub ptr: &'a PagePointer, pub info: &'a DocInfo, pub tool: QuickTool, pub prefs: &'a CommentPrefs, @@ -808,8 +811,7 @@ pub(crate) fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2, aspect_ratio: /// Comment input on one page, before text selection runs. Returns `true` when the pointer /// gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); + let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); @@ -1129,8 +1131,8 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati /// Where a gesture on this page is decided: the press while the button is down, otherwise the /// pointer (egui clears the press origin on release). `None` off the page. fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option { - let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down())); - (if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p)) + let down = ui.input(|i| i.pointer.any_down()); + (if down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p)) } /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`. @@ -1320,7 +1322,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut /// Paint comment selection, hover and gesture previews on a page. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { let cv = &view.comments; - let pointer = ui.input(|i| i.pointer.hover_pos()); + let pointer = cx.ptr.pos; // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the // marked area reads before anything is applied. if cv.gesture.is_none() @@ -1471,7 +1473,7 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => { // The vertices so far, and a rubber band to the pointer. let mut line = pts.clone(); - if let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) { + if let Some(p) = cx.ptr.pos { line.push(p); } painter.add(egui::Shape::line(line, stroke)); @@ -1582,7 +1584,17 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, } return None; }; - let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) }; + let cx = PageCx { + page, + xf: &xf, + ptr: &PagePointer::default(), + info, + tool: QuickTool::Select, + prefs, + allowed: true, + hidden: false, + redact_mark: ([0.89, 0.13, 0.13], 1.0), + }; let anchor = cx.to_screen(c.at); let viewport = view.viewport_rect(); let t = Tokens::get(ctx); @@ -2326,6 +2338,7 @@ mod tests { let cx = PageCx { page: 0, xf: &xf, + ptr: &PagePointer::default(), info: &info, tool: QuickTool::Select, prefs: &prefs, diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 2ac624d3fc..013904988a 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::widgets; @@ -198,17 +199,18 @@ impl MoveBackground { } /// The page without the moving item, then the item's pixels taken from `page_tex` (the page - /// as rendered, item included where it was) at the dragged or applied position. + /// as rendered, item included where it was) at the dragged (to the pages' `pointer`) or + /// applied position. #[allow(clippy::too_many_arguments)] pub fn paint( &self, - ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, cv: &ContentView, page_tex: Option, + pointer: Option, ) { let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; if kp != page { @@ -216,7 +218,7 @@ impl MoveBackground { } let r = screen_rect(xf, info, page, from); let to = if let Some((_, grab, start)) = cv.grab { - let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + let Some(p) = pointer else { return }; dragged(r, grab, p - start, self.picture) } else if let Some(to) = self.settling { let to = screen_rect(xf, info, page, to); @@ -302,14 +304,12 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { } /// Added text and images keep pointer ownership over a form field while editing content. -pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { +pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { let cv = &view.content; if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) { return true; } - let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else { - return false; - }; + let Some(p) = ptr.within(xf.rect) else { return false }; on_page(added, page).into_iter().any(|(index, item)| { let rect = screen_rect(xf, info, page, item.content.rect()); rect.expand(2.0).contains(p) @@ -324,6 +324,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], @@ -333,7 +334,7 @@ pub(crate) fn page_input( view: &mut DocView, ) -> bool { let mut consumed = false; - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let cv = &mut view.content; if let Some((gp, grab, start)) = cv.grab && gp == page @@ -408,7 +409,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { cv.grab = Some((page, Grab::Corner(left, top), origin)); consumed = true; @@ -450,10 +451,18 @@ pub(crate) fn page_input( consumed || hit.is_some() && resp.is_pointer_button_down_on() } -/// Outlines of added items (hover and selection with handles) and the box being dragged. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) { +/// Outlines of added items (hover and selection with handles) and the box being dragged, the +/// pages' `pointer` over them. +pub(crate) fn paint_page( + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + added: &[Added], + view: &DocView, + pointer: Option, +) { let cv = &view.content; - let pointer = ui.input(|i| i.pointer.hover_pos()); for (i, a) in on_page(added, page) { if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) { continue; diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index 1344a30f33..1f1e1aa131 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; /// A crop rectangle being dragged: (page, start on screen). pub type CropDrag = Option<(usize, Pos2)>; @@ -17,17 +18,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Crop gestures on one page. Returns `Some(true)` to open Set Page Boxes for this page. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, view: &mut DocView, allowed: bool, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 65c7bd780a..ec803cf6e6 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -497,6 +498,7 @@ fn crop_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, view: &mut DocView, @@ -506,12 +508,12 @@ fn crop_input( ) -> bool { let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; - let hover_pos = ui.input(|inp| inp.pointer.hover_pos()); + let hover_pos = ptr.pos; let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); // A drag in progress moves a grip; the box stays inside the picture and at least 6 px. if allow_interaction && resp.drag_started() - && let Some(o) = ui.input(|inp| inp.pointer.press_origin()) + && let Some(o) = ptr.origin { let k = keep_screen(mode.keep); let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8)); @@ -664,7 +666,7 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture /// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same /// applies after the pointer leaves the image/page while dragging. -pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { +pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool { let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; if selection.drag.is_some() { return true; @@ -672,7 +674,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; let Some(rect) = image.visible_rect else { return false }; let b = xf.user_rect(info, page, rect.map(|v| v as f32)); - let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() }); + let p = if ui.input(|i| i.pointer.primary_down()) { ptr.origin } else { ptr.pos }; let knob = rotate_knob(b, ui.clip_rect()).0; p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0)) } @@ -800,6 +802,7 @@ pub(crate) fn image_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], @@ -809,7 +812,7 @@ pub(crate) fn image_input( ) -> bool { let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); let painter = ui.painter(); - let hover_pos = ui.input(|i| i.pointer.hover_pos()); + let hover_pos = ptr.pos; let image_contains = |index: usize, point: Pos2| { let (x, y) = xf.screen_to_view(point); let Some(p) = info.pages.get(page) else { return false }; @@ -853,7 +856,7 @@ pub(crate) fn image_input( if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction); + return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; let handles = |rect: Rect| { @@ -903,10 +906,9 @@ pub(crate) fn image_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } } - let origin = ui.input(|i| i.pointer.press_origin()); if allow_interaction && resp.drag_started() - && let Some(o) = origin + && let Some(o) = ptr.origin && o.distance(knob) < 8.0 { if let Some(s) = view.image_selection.as_mut() { @@ -915,7 +917,7 @@ pub(crate) fn image_input( } } else if allow_interaction && resp.drag_started() - && let Some(o) = origin + && let Some(o) = ptr.origin { let corner = corners.iter().position(|c| c.distance(o) < 8.0); if corner.is_some() || image_contains(i, o) { @@ -1209,6 +1211,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, doc: &pdfcraft_engine::Document, lines: &[pdfcraft_engine::TextBlock], @@ -1256,7 +1259,7 @@ pub(crate) fn page_input( if let Some(d) = view.block_drag.filter(|d| d.page == page) && let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block)) { - let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start); + let p = ptr.pos.unwrap_or(d.start); let dx = p.x - d.start.x; let preview = match d.grip { Grip::Move => b.translate(p - d.start), @@ -1301,13 +1304,13 @@ pub(crate) fn page_input( // quick flick has left the edge by then. if resp.drag_started() && active.is_none() - && let Some(o) = ui.input(|i| i.pointer.press_origin()) + && let Some(o) = ptr.origin && let Some((block, grip)) = grab_at(&boxes, o, upright) { view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); return true; } - let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false }; + let Some(p) = ptr.within(xf.rect) else { return false }; let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false }; if active != Some(hit) { painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 1684c80bea..862b63e72d 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -626,6 +627,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, tool: FillTool, @@ -637,10 +639,7 @@ pub(crate) fn page_input( author: &str, date_text: &Result, ) -> Option { - let pointer = ui.input(|i| i.pointer.hover_pos())?; - if !resp.contains_pointer() || !xf.rect.contains(pointer) { - return None; - } + let pointer = ptr.within(xf.rect)?; let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); let saved = match tool { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 0d81e547ca..6935eab65a 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -9,11 +9,12 @@ use std::collections::HashSet; -use egui::{Color32, CornerRadius, Rect, Stroke, vec2}; +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -165,14 +166,14 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, form: &[FormField], allowed: bool, view: &mut DocView, ) -> FieldPointer { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away }; + let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away }; let hit = form.iter().find_map(|f| { f.widgets .iter() @@ -475,9 +476,16 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st picked } -/// Paint focus and hover frames on a page. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); +/// Paint focus and hover frames on a page, the pages' `pointer` over it. +pub(crate) fn paint_page( + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + view: &DocView, + pointer: Option, +) { for f in form { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) { let r = widget_rect(xf, info, page, w.rect); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e50145f8c9..c1b89dc86b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,7 @@ pub mod icons; pub mod last_session; pub mod last_view; pub mod motion; +mod page_pointer; mod page_selection_ui; mod pageboxes; mod palette; diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index c714870c53..55728194d0 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -56,16 +57,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Link tool input on one page. Returns `true` when the gesture belongs to the tool. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, links: &[LinkItem], view: &mut DocView, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let lv = &mut view.links; if let Some((dp, start)) = lv.drag && dp == page @@ -104,7 +107,7 @@ pub(crate) fn page_input( return true; } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); lv.drag = Some((page, origin)); return true; } @@ -112,7 +115,7 @@ pub(crate) fn page_input( } /// Outlines of the page's links (selected one bold) and the rectangle being drawn. -pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView) { +pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView, pointer: Option) { for l in links.iter().filter(|l| l.page == page) { let r = screen_rect(xf, info, page, l.rect); let selected = view.links.selected == Some((page, l.index)); @@ -123,7 +126,7 @@ pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page egui::StrokeKind::Outside, ); } - if let (Some((dp, start)), Some(p)) = (view.links.drag, ui.input(|i| i.pointer.hover_pos())) + if let (Some((dp, start)), Some(p)) = (view.links.drag, pointer) && dp == page { let r = Rect::from_two_pos(start, p); diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 7c675c694a..00dad4bd5f 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -196,7 +196,7 @@ pub(crate) fn page_input( let v = info.user_to_view(p[0] as f32, p[1] as f32); xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph) }; - let pos = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)); + let pos = cx.ptr.within(xf.rect); let mut hover = None; let mut snap = None; if let Some(pos) = pos { diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index b68fe352ae..ed39e4b206 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -156,15 +157,22 @@ fn leave_single(view: &mut DocView) { /// Returns true while group selection owns the interaction. Ordinary clicks still reach the /// single-object text/resize tools. A text draft is finished or discarded through its editor. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + doc: &Document, + view: &mut DocView, +) -> bool { if view.objects.input_blocked { return true; } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); - let modifier_press = ui.input(|i| i.pointer.primary_pressed()) - && modified(ui) - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); + let modifier_press = + ui.input(|i| i.pointer.primary_pressed()) && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); if blocked || modifier_press { view.objects.hold_editor = true; view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page); @@ -176,14 +184,24 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p return false; } let mut selection = std::mem::take(&mut view.objects); - let result = input(&mut selection, ui, resp, xf, page, doc, view); + let result = input(&mut selection, ui, resp, xf, ptr, page, doc, view); view.objects = selection; result } #[allow(clippy::too_many_arguments)] -fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { - let (pointer, pressed, down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.primary_pressed(), i.pointer.primary_down())); +fn input( + s: &mut ObjectSelection, + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + doc: &Document, + view: &mut DocView, +) -> bool { + let pointer = ptr.pos; + let (pressed, down) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); let info = &doc.info; if s.page == Some(page) && let Some(mut gesture) = s.gesture diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs new file mode 100644 index 0000000000..6466f2a877 --- /dev/null +++ b/crates/ui-egui/src/page_pointer.rs @@ -0,0 +1,207 @@ +//! The pointer as the pages see it, read once per frame from the page view's content widget. +//! +//! egui knows what is drawn over the pages (a dialog, a menu, the palette, the quick bar, the +//! chrome over a page scrolled under it) and keeps a press with the widget it began on until +//! release. egui's raw pointer state knows neither, and its hover position is gone on the frame +//! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and +//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never +//! `hover_pos`, `latest_pos` or `interact_pos`. + +use egui::{Event, PointerButton, Pos2, Rect}; + +/// A primary-button press or release, and where it happened. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Edge { + Press(Pos2), + Release(Pos2), +} + +/// How many of a frame's presses and releases are kept. A frame holds one or two (a click, a +/// double-click in a slow frame), but one after a stall can hold any number. +const MAX_EDGES: usize = 8; + +/// One frame's pointer, as the pages see it. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct PagePointer { + /// Where the pointer is, while the pages are what egui has under it, or while they hold a + /// press (until the frame of its release, past a page's edge included). It is egui's + /// `interact_pos`, which still has the position on the frame a touch lifts. + pub pos: Option, + /// Where the press the pages hold began, also on the frame of its release when the press + /// came in that same frame (egui has forgotten it by then). `None` for a press that began on + /// something else. + pub origin: Option, + /// This frame's primary presses and releases of a press the pages hold, in order. + pub edges: Vec, +} + +impl PagePointer { + /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`. + pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response) -> Self { + let (at, origin, edges) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events))); + // egui's own answer to "do the pages hold a press?": from the press on until the frame + // of its click, release or drag stop. A drag can outlive egui's press while the + // pointer is outside the window. + let held = resp.dragged() || resp.interact_pointer_pos().is_some(); + // `contains_pointer` alone also holds a few points under the edge of an Area over the + // pages (egui's interact radius), so the top layer there must be the pages' own. + let layer = ui.layer_id(); + let on_top = |p: &Pos2| resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); + let pos = at.filter(|p| held || on_top(p)); + if !held { + return Self { pos, origin: None, edges: Vec::new() }; + } + Self { pos, origin: origin.or_else(|| first_press(&edges)), edges } + } + + /// Where the pointer is, when it is inside `r`. + pub(crate) fn within(&self, r: Rect) -> Option { + self.pos.filter(|p| r.contains(*p)) + } + + /// Where this frame's first primary press was. + pub(crate) fn pressed(&self) -> Option { + first_press(&self.edges) + } +} + +fn first_press(edges: &[Edge]) -> Option { + edges.iter().find_map(|e| match e { + Edge::Press(p) => Some(*p), + Edge::Release(_) => None, + }) +} + +/// The first [`MAX_EDGES`] primary presses and releases among `events`, in order. +fn edges(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, .. } if pos.is_finite() => { + Some(if *pressed { Edge::Press(*pos) } else { Edge::Release(*pos) }) + } + _ => None, + }) + .take(MAX_EDGES) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Context, Modifiers, Sense, TouchDeviceId, TouchId, TouchPhase, pos2, vec2}; + + /// The pages' widget: x 0..200, y 0..200 of a 400×400 screen. + const PAGES: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 200.0, y: 200.0 } }; + /// An Area drawn over the pages' lower right corner. + const OVER: Rect = Rect { min: Pos2 { x: 120.0, y: 120.0 }, max: Pos2 { x: 260.0, y: 260.0 } }; + + fn button(pos: Pos2, button: PointerButton, pressed: bool) -> Event { + Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE } + } + + fn touch(phase: TouchPhase, pos: Pos2) -> Event { + Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None } + } + + /// Runs one frame of the pages' widget (and the Area over it, with `area`) and returns what + /// the pages see. + fn frame(ctx: &Context, events: Vec, area: bool) -> PagePointer { + let mut seen = PagePointer::default(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag()); + seen = PagePointer::read(ui, &resp); + if area { + egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { + ui.allocate_exact_size(OVER.size(), Sense::hover()); + }); + } + }); + // An input-only test has no renderer to take the font texture. + output.textures_delta.clear(); + seen + } + + #[test] + fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![], false); + let down = frame(&ctx, vec![touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, PointerButton::Primary, true)], false); + assert_eq!((down.pos, down.origin, down.pressed()), (Some(p), Some(p), Some(p))); + // egui-winit ends a touch with the release and `PointerGone` in one frame. + let up = frame(&ctx, vec![touch(TouchPhase::End, p), button(p, PointerButton::Primary, false), Event::PointerGone], false); + assert_eq!(ctx.input(|i| i.pointer.hover_pos()), None, "egui's hover position is gone"); + assert_eq!(up.pos, Some(p), "the pages still know where the finger lifted"); + assert_eq!(up.edges, [Edge::Release(p)]); + assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after"); + } + + #[test] + fn a_press_and_release_in_one_frame_keep_the_press() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let click = frame(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false); + assert_eq!(ctx.input(|i| i.pointer.press_origin()), None, "egui has forgotten the press"); + assert_eq!(click.origin, Some(p)); + assert_eq!(click.edges, [Edge::Press(p), Edge::Release(p)]); + } + + #[test] + fn something_drawn_over_the_pages_hides_the_pointer() { + let ctx = Context::default(); + // The Area and the widget rects egui hit-tests against come from the frame before. + frame(&ctx, vec![], true); + frame(&ctx, vec![], true); + let beside = pos2(100.0, 100.0); + assert_eq!(frame(&ctx, vec![Event::PointerMoved(beside)], true).pos, Some(beside)); + // Inside the Area, and just inside its edge where egui's interact radius still finds the + // pages' widget. + for p in [pos2(160.0, 160.0), pos2(122.0, 150.0)] { + assert_eq!(frame(&ctx, vec![Event::PointerMoved(p)], true).pos, None, "{p:?}"); + } + // Off the pages altogether. + assert_eq!(frame(&ctx, vec![Event::PointerMoved(pos2(300.0, 50.0))], true).pos, None); + // A press on the Area is not the pages' press. + let p = pos2(160.0, 160.0); + let press = frame(&ctx, vec![button(p, PointerButton::Primary, true)], true); + assert_eq!(press, PagePointer::default()); + } + + #[test] + fn a_press_on_the_pages_keeps_the_pointer_until_its_release() { + let ctx = Context::default(); + frame(&ctx, vec![], true); + frame(&ctx, vec![], true); + let start = pos2(40.0, 40.0); + frame(&ctx, vec![Event::PointerMoved(start), button(start, PointerButton::Primary, true)], true); + // Dragged over the Area and off the pages: still theirs. + for p in [pos2(160.0, 160.0), pos2(300.0, 50.0)] { + let held = frame(&ctx, vec![Event::PointerMoved(p)], true); + assert_eq!((held.pos, held.origin), (Some(p), Some(start)), "{p:?}"); + } + let end = pos2(300.0, 60.0); + let up = frame(&ctx, vec![Event::PointerMoved(end), button(end, PointerButton::Primary, false)], true); + assert_eq!((up.pos, up.edges.as_slice()), (Some(end), [Edge::Release(end)].as_slice()), "the release is the pages' too"); + assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs"); + } + + #[test] + fn edges_are_primary_finite_in_order_and_capped() { + let p = pos2(1.0, 2.0); + let q = pos2(3.0, 4.0); + let events = vec![ + button(p, PointerButton::Secondary, true), + button(p, PointerButton::Primary, true), + Event::PointerMoved(q), + button(pos2(f32::NAN, 0.0), PointerButton::Primary, false), + button(q, PointerButton::Primary, false), + ]; + assert_eq!(edges(&events), [Edge::Press(p), Edge::Release(q)]); + let many: Vec = (0..100).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect(); + assert_eq!(edges(&many).len(), MAX_EDGES); + assert_eq!(first_press(&[Edge::Release(q), Edge::Press(p), Edge::Press(q)]), Some(p)); + } +} diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 5833c38384..34a5566347 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::comments; +use crate::page_pointer::PagePointer; use crate::theme; use crate::widgets; @@ -242,6 +243,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, form: &[FormField], @@ -250,7 +252,7 @@ pub(crate) fn page_input( view: &mut DocView, ) -> Outcome { let mut out = Outcome { consumed: false, properties: false, placed: false, table: None }; - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let prep = &mut view.prepare; if prep.marquee_before.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { @@ -337,7 +339,7 @@ pub(crate) fn page_input( if let Some(tool) = tool { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.drag_started() || resp.clicked() { - let origin = ui.input(|i| i.pointer.press_origin()).filter(|o| xf.rect.contains(*o)).unwrap_or(p); + let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); prep.grab = Some((page, Grab::Draw(origin), origin)); out.consumed = true; if resp.clicked() { @@ -393,7 +395,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { prep.grab = Some((page, Grab::Corner(left, top), origin)); out.consumed = true; @@ -538,10 +540,19 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String, } /// Fields with their light-blue fill and name tags, the selection with its handles, and the -/// rectangle being drawn or dragged. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { +/// rectangle being drawn or dragged to the pages' `pointer`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_page( + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + view: &DocView, + pointer: Option, +) { let prep = &view.prepare; - let pointer = ui.input(|i| i.pointer.hover_pos()); let grab = prep.grab.filter(|(gp, ..)| *gp == page); let zoom = xf.zoom().clamp(0.5, 3.0); for f in form { diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 412fc95997..4058ae695e 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS, use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::{PdfCraftApp, widgets}; @@ -234,16 +235,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { /// Redact tool input on a page. Presses on text are left to text selection (the selection is /// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when /// the gesture is a box. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, over_text: impl Fn(Pos2) -> bool, view: &mut DocView, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; if let Some((dp, start)) = view.redact_drag && dp == page { @@ -263,7 +266,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if !over_text(origin) { view.redact_drag = Some((page, origin)); return true; @@ -283,9 +286,9 @@ pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, vie } } -/// The box being drawn. -pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, page: usize, view: &DocView) { - if let (Some((dp, start)), Some(p)) = (view.redact_drag, ui.input(|i| i.pointer.hover_pos())) +/// The box being drawn, to the pages' `pointer`. +pub(crate) fn paint(painter: &egui::Painter, page: usize, view: &DocView, pointer: Option) { + if let (Some((dp, start)), Some(p)) = (view.redact_drag, pointer) && dp == page { let r = Rect::from_two_pos(start, p); diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 25f92530b2..6d09115b74 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -151,9 +152,16 @@ pub struct SignView { } /// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, info: &pdfcraft_render::DocInfo, view: &mut DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + info: &pdfcraft_render::DocInfo, + view: &mut DocView, +) { + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index 788f333e68..e574d68ec4 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -186,8 +186,8 @@ impl SignatureDrag { { return; } - let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift)); - let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending); + let shift = painter.ctx().input(|i| i.modifiers.shift); + let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), cx.ptr.pos, shift, pending); let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect)); cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); match &layers.front { diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index eb2cc86727..e0315763fa 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::{PdfCraftApp, QuickTool}; /// A finished gesture: (page, the rectangle on screen, the same in page view points @@ -12,9 +13,8 @@ use crate::{PdfCraftApp, QuickTool}; pub type Marquee = (usize, Rect, [f32; 4]); /// Drag a rectangle on page `page` (Marquee Zoom, Snapshot). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index a273e3cd62..4980fd66b5 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2172,6 +2172,9 @@ fn markup_hover_harness(pdf: Vec, view_rotation: u16) -> Harness<'static, Pd app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); + // Small enough to show the whole page in every rotation: a point hovered below the window + // is not over the page. + app.set_option("zoom", "100").unwrap(); app.views[0].rotation = view_rotation; app }); @@ -2193,7 +2196,9 @@ fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) { pw: page.width, ph: page.height, }; - xf.user_rect(info, 0, [x, y, x, y]).center() + let p = xf.user_rect(info, 0, [x, y, x, y]).center(); + assert!(v.viewport_rect().contains(p), "user point ({x}, {y}) is on screen, at {p:?}"); + p }; h.hover_at(p); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b49642fe04..39807445b5 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -57,6 +57,7 @@ mod pageboxes; mod panel_snapshots; mod pdf_links; mod pickers; +mod pointer; mod preferences_layout; mod prepare; mod print; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs new file mode 100644 index 0000000000..73af2373ce --- /dev/null +++ b/crates/ui-egui/tests/it/pointer.rs @@ -0,0 +1,531 @@ +//! What the page's tools see of the pointer. A finger tap, a touch drag and a click whose press +//! and release share a frame act as a mouse click and drag do, and nothing drawn over the page (a +//! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's +//! cursor, tips or presses through. +//! +//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor +//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4). + +use egui_kittest::Harness; +use pdfcraft_engine::StampKind; +use pdfcraft_ui_egui::comments::CommentTool; +use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; + +use crate::support::input::{coalesced_click, double_click, touch_drag, touch_tap}; + +/// A PDF of `objects`, numbered from 1 with the catalog first. +fn pdf(objects: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1) +} + +const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>"; +const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>"; +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; +/// One line of 12 pt Helvetica at x 40, y 300. +const LINE: &str = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET"; + +/// One 300×400 page with a web link at x 50..250, y 300..350. +fn link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// A 300×400 page with a paragraph at y≈300 and a link over its first words. +fn linked_text_pdf() -> Vec { + let text = stream("BT /F1 12 Tf 40 300 Td (Read the guide at example.org before you start.) Tj ET"); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [40 295 160 312] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + text.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with a line of text at y≈300 and a rectangle comment over it. +fn comment_on_text_pdf() -> Vec { + let text = stream(LINE); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /Border [0 0 2] /F 4 /T (Reviewer) >>", + text.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with one line of text at x 40, y 300. +fn text_pdf() -> Vec { + let text = stream(LINE); + pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", text.as_str(), HELVETICA]) +} + +/// A 300×400 page with an unchecked check box `agree` at x 100..120, y 200..220. +fn checkbox_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + ]) +} + +/// The app with `pdf` open at `zoom` percent and its first frames run. The left panel stays +/// open only where a test places the page relative to the window's chrome. +fn open(pdf: Vec, zoom: &'static str, left_open: bool) -> Harness<'static, PdfCraftApp> { + 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("test.pdf", None, pdf).unwrap(); + if !left_open { + app.set_option("left", "closed").unwrap(); + } + app.set_option("zoom", zoom).unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + h +} + +fn stamp_tool(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().quick_tool = QuickTool::Stamp(StampKind::DynApproved); + h.run_steps(4); +} + +fn page(h: &Harness<'_, PdfCraftApp>) -> egui::Rect { + h.state().views[0].page_screen_rect(0).expect("page on screen") +} + +/// The screen point at PDF (x, y) on the 300×400 page. +fn at(h: &Harness<'_, PdfCraftApp>, x: f32, y: f32) -> egui::Pos2 { + let r = page(h); + let k = r.width() / 300.0; + r.min + egui::vec2(x * k, (400.0 - y) * k) +} + +fn cursor_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon { + h.hover_at(p); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +/// A mouse click: press and release in frames of their own, as `drag_at` and `drop_at` send them. +fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); +} + +/// A mouse drag from `from` by `by`, held over two frames on the way. +fn mouse_drag(h: &mut Harness<'_, PdfCraftApp>, from: egui::Pos2, by: egui::Vec2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + by); + h.run_steps(2); + h.drop_at(from + by); + for _ in 0..10 { + h.run_steps(2); + } +} + +fn stamps(h: &Harness<'_, PdfCraftApp>) -> usize { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").count() +} + +fn square(h: &Harness<'_, PdfCraftApp>) -> Option<[f32; 4]> { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.subtype == "Square").map(|a| a.rect) +} + +fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone() +} + +/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from +/// worker threads), and returns the last cursor seen. +fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { + let mut cursor = egui::CursorIcon::Default; + for _ in 0..50 { + cursor = cursor_at(h, p); + if want(cursor) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + cursor +} + +// --------------------------------------------------------------------------------------------- +// The cursor promises one thing and a press does another. + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn stamp_tool_over_a_link_shows_its_crosshair() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + let off_link = at(&h, 150.0, 150.0); + assert_eq!(cursor_at(&mut h, off_link), egui::CursorIcon::Crosshair, "the tool's cursor off the link"); + let cursor = cursor_at(&mut h, on_link); + mouse_click(&mut h, on_link); + assert_eq!((stamps(&h), h.state().pending_link.is_some()), (1, false), "a click places a stamp and does not follow the link"); + assert_eq!(cursor, egui::CursorIcon::Crosshair, "so the cursor is the stamp tool's, not the link hand"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn rectangle_tool_over_a_link_shows_its_crosshair() { + let mut h = open(link_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let off_link = at(&h, 150.0, 150.0); + let off = cursor_at(&mut h, off_link); + let on_link = at(&h, 150.0, 325.0); + let on = cursor_at(&mut h, on_link); + assert_eq!(on, off, "a drag over the link draws a rectangle, as it does off the link"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn edit_text_over_a_linked_word_offers_the_move_arrow() { + let mut h = open(linked_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + for _ in 0..30 { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // One paragraph, one line: a word outside the link, then a word inside it. + let plain = at(&h, 230.0, 303.0); + let linked = at(&h, 60.0, 303.0); + let off = wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Move); + let on = cursor_at(&mut h, linked); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + let k = page(&h).width() / 300.0; + mouse_drag(&mut h, linked, egui::vec2(20.0 * k, 0.0)); + let after = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + assert_eq!(off, egui::CursorIcon::Move, "a closed paragraph offers the four-way arrow (#1717)"); + assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the linked word moves the paragraph: {before:?} → {after:?}"); + assert_eq!(on, off, "the linked words belong to the same paragraph, and a drag there moves it too"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn select_tool_over_a_comment_on_text_offers_the_move_arrow() { + let mut h = open(comment_on_text_pdf(), "150", false); + // Inside the rectangle where there is no text, and inside it over a word. + let blank = at(&h, 120.0, 315.0); + let on_text = at(&h, 60.0, 303.0); + let over_blank = cursor_at(&mut h, blank); + let over_text = wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); + let before = square(&h).expect("the rectangle"); + let k = page(&h).width() / 300.0; + mouse_drag(&mut h, on_text, egui::vec2(20.0 * k, 0.0)); + let after = square(&h).expect("the rectangle"); + assert_eq!(over_blank, egui::CursorIcon::Move, "a movable comment offers the four-way arrow"); + assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the text moves the rectangle: {before:?} → {after:?}"); + assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too"); +} + +/// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points +/// left of the edge. Returns the cursor shown at the press point and the line count after. +fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) { + let mut h = open(text_pdf(), "150", false); + assert!(h.state_mut().execute("edit.edit_text")); + for _ in 0..20 { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let id = h.state().views[0].id; + let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let k = page(&h).width() / 300.0; + let grip = at(&h, b.rect[0] as f32, (b.rect[1] + b.rect[3]) as f32 / 2.0) - egui::vec2(dx, 0.0); + let cursor = cursor_at(&mut h, grip); + h.drag_at(grip); + h.run_steps(1); + for i in 1..=4 { + h.hover_at(grip + egui::vec2(25.0 * k * i as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(grip + egui::vec2(100.0 * k, 0.0)); + for _ in 0..10 { + h.run_steps(2); + } + (cursor, h.state().session.get(id).unwrap().text_lines(0).len()) +} + +#[test] +#[ignore = "step 3 of the page pointer router: one hit test"] +fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() { + let (inside_cursor, inside_lines) = narrow_from_left(1.0); + assert_eq!(inside_cursor, egui::CursorIcon::ResizeHorizontal); + assert!(inside_lines > 1, "control: a drag from the edge rewraps the paragraph"); + let (outside_cursor, outside_lines) = narrow_from_left(5.0); + assert_eq!(outside_cursor, egui::CursorIcon::ResizeHorizontal, "the resize cursor shows 5 pt out"); + assert!(outside_lines > 1, "so a drag from there rewraps it too"); +} + +// --------------------------------------------------------------------------------------------- +// Things drawn over the page keep the page's cursor, tips and presses off. + +#[test] +fn stamp_cursor_stays_off_the_command_palette() { + let mut h = open(link_pdf(), "300", false); + stamp_tool(&mut h); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown"); + // On the palette's frame margin, where none of its widgets sets a cursor, over the page. + let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0); + assert!(page(&h).contains(p), "the palette must lie over the page here"); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default); + // The page beside the palette still shows the tool. + let beside = egui::pos2(palette.left() + 4.0, palette.bottom() + 40.0); + assert!(page(&h).contains(beside)); + assert_eq!(cursor_at(&mut h, beside), egui::CursorIcon::Crosshair); +} + +fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { + h.hover_at(egui::pos2(800.0, 500.0)); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move }); + for _ in 0..10 { + h.run_steps(2); + } +} + +#[test] +fn stamp_cursor_stays_off_the_quick_bar() { + let mut h = open(link_pdf(), "400", true); + stamp_tool(&mut h); + // Bring the page under the quick bar's left end. + wheel(&mut h, egui::vec2(-200.0, 0.0)); + let bar = h.ctx.memory(|m| m.area_rect(egui::Id::new("quick-bar"))).expect("quick bar shown"); + // Inside the bar's frame margin: no button there. + let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0); + assert!(page(&h).contains(p), "the quick bar must lie over the page here"); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default); + let below = egui::pos2(bar.left() + 3.0, bar.bottom() + 20.0); + assert!(page(&h).contains(below)); + assert_eq!(cursor_at(&mut h, below), egui::CursorIcon::Crosshair); +} + +#[test] +fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() { + let mut h = open(link_pdf(), "400", true); + stamp_tool(&mut h); + let top = page(&h).top(); + wheel(&mut h, egui::vec2(0.0, -300.0)); + let page = page(&h); + // Above where the page started (the viewport's top edge, give or take its margin): the + // toolbar, with the page scrolled up under it. Left of the link, which shows the hand. + let x = page.left() + 20.0; + let p = egui::pos2(x, top - 45.0); + assert!(page.contains(p), "the page must extend under the chrome here"); + // The toolbar keeps its own cursor (a button's hand here); the page's is gone. + assert_ne!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair); + assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair); +} + +#[test] +fn link_tip_stays_under_the_unlock_prompt() { + let tip_shown = |h: &Harness<'_, PdfCraftApp>| { + let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); + h.ctx.memory(|m| m.areas().is_visible(&tip)) + }; + let mut h = open(link_pdf(), "300", true); + let page = page(&h); + let k = page.width() / 300.0; + // Near the link's top left corner, clear of the prompt's card in the middle of the window. + let on_link = page.min + egui::vec2(55.0 * k, (400.0 - 345.0) * k); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + + let doc = h.state().views[0].id; + h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); + h.hover_at(on_link + egui::vec2(1.0, 0.0)); + for _ in 0..4 { + h.run_steps(2); + } + let card = h.ctx.memory(|m| m.area_rect(egui::Id::new("unlock"))); + assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}"); + assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); +} + +#[test] +fn the_link_tip_lets_the_pointer_through_to_the_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); + let tip = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-hover"))).expect("the link's tip"); + // A quick move lands on the tip, drawn beside where the pointer was, and still on the link. + let onto = tip.min + egui::vec2(6.0, 6.0); + assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link"); + h.hover_at(onto); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link"); +} + +#[test] +fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() { + let mut h = open(text_pdf(), "150", false); + let word = at(&h, 50.0, 303.0); + // The I-beam shows the page's text layer has arrived. + assert_eq!(wait_for_cursor(&mut h, word, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page text is live"); + // Control: without a dialog, a double-click there selects the word. + double_click(&mut h, word); + h.run_steps(4); + assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "control: a double-click selects the word"); + // A click on blank page clears it again. + h.run_steps(30); + let p = at(&h, 150.0, 150.0); + mouse_click(&mut h, p); + h.run_steps(30); + assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "cleared before the dialog opens"); + h.state_mut().set_option("dialog", "shortcuts").unwrap(); + h.run_steps(6); + assert!(h.state().dialog.is_some(), "a modal dialog is open"); + // On the dialog's backdrop, where the page's first word lies underneath. + double_click(&mut h, word); + h.run_steps(4); + let selected = h.state().views[0].selected_text(); + assert!(selected.as_deref().is_none_or(str::is_empty), "a double-click on a dialog selects nothing on the page under it: {selected:?}"); +} + +// --------------------------------------------------------------------------------------------- +// Taps, touch drags and clicks within one frame. On the frame a touch (or a Windows pen) lifts, +// egui-winit has sent `PointerGone`, so there is no hover position; egui still reports the click. + +#[test] +fn mouse_click_follows_a_link() { + // Control for the tap below. + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + mouse_click(&mut h, p); + assert!(h.state().pending_link.is_some(), "a click on a link asks to open it"); +} + +#[test] +fn touch_tap_follows_a_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + touch_tap(&mut h, p); + assert!(h.state().pending_link.is_some(), "a tap on a link asks to open it, as a click does"); +} + +#[test] +fn coalesced_click_follows_a_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + coalesced_click(&mut h, p); + assert!(h.state().pending_link.is_some(), "a click whose press and release share a frame follows the link"); +} + +#[test] +fn touch_tap_places_a_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + touch_tap(&mut h, p); + assert_eq!(stamps(&h), 1, "a tap with the stamp tool places the stamp"); +} + +#[test] +fn coalesced_click_places_a_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + coalesced_click(&mut h, p); + assert_eq!(stamps(&h), 1, "a click whose press and release share a frame places the stamp"); +} + +#[test] +fn mouse_click_ticks_a_check_box() { + // Control for the tap below. + let mut h = open(checkbox_pdf(), "150", false); + let p = at(&h, 110.0, 210.0); + mouse_click(&mut h, p); + assert_eq!(agree(&h), ["Yes"], "a click ticks the box"); +} + +#[test] +#[ignore = "the check box focus (#2223) counts a tap as a click away: fixed by a later commit"] +fn touch_tap_ticks_a_check_box() { + let mut h = open(checkbox_pdf(), "150", false); + let p = at(&h, 110.0, 210.0); + touch_tap(&mut h, p); + assert_eq!(agree(&h), ["Yes"], "a tap ticks the box, as a click does"); +} + +#[test] +fn touch_drag_moves_a_comment_and_leaves_no_gesture_behind() { + let mut h = open(comment_on_text_pdf(), "150", false); + let k = page(&h).width() / 300.0; + let before = square(&h); + // A finger drags the rectangle 30 pt right, from a blank spot inside it. + let from = at(&h, 120.0, 315.0); + touch_drag(&mut h, from, from + egui::vec2(30.0 * k, 0.0)); + let after_touch = square(&h); + assert_ne!(after_touch, before, "the touch drag moves the rectangle"); + // Later, a mouse drag on empty page far below it. + let p = at(&h, 150.0, 100.0); + mouse_drag(&mut h, p, egui::vec2(10.0 * k, 10.0 * k)); + assert_eq!(square(&h), after_touch, "an unrelated drag elsewhere leaves the rectangle where the touch put it"); +} + +// --------------------------------------------------------------------------------------------- +// Ending a gesture. + +#[test] +#[ignore = "step 4 of the page pointer router: press-time capture and cancel"] +fn escape_cancels_a_signature_rectangle() { + let mut h = open(link_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::SignArea { certify: false }; + h.run_steps(4); + let (a, b) = (at(&h, 60.0, 200.0), at(&h, 200.0, 120.0)); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + h.hover_at(b); + h.run_steps(2); + // Escape while the button is still held. + h.key_press(egui::Key::Escape); + h.run_steps(4); + let s = h.state(); + assert_eq!(s.quick_tool, QuickTool::SignArea { certify: false }, "Escape abandons the rectangle; the tool stays"); + assert!(s.dialog.is_none(), "and no signing dialog opens: {:?}", s.dialog); +} diff --git a/crates/ui-egui/tests/it/support/input.rs b/crates/ui-egui/tests/it/support/input.rs new file mode 100644 index 0000000000..0030c463d4 --- /dev/null +++ b/crates/ui-egui/tests/it/support/input.rs @@ -0,0 +1,65 @@ +//! Pointer input in the frames the platform delivers it in. +//! +//! `Harness::step` runs one frame for every queued event, each 0.25 s after the last, so input +//! sent through `Harness::event` (or `drag_at` and `drop_at`) never shares a frame. Real input +//! does: egui-winit turns each touch (and on Windows each pen stroke, which winit reports as +//! touch) into a `Touch` event plus pointer events in one frame, and sends `PointerGone` with +//! every release, so `hover_pos()` is `None` on the frame egui reports the tap. A slow frame +//! can also hold a whole click. These helpers push each frame's events into +//! `Harness::input_mut` and step once. +use egui::{Event, Modifiers, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; +use egui_kittest::Harness; + +/// Runs one frame with all of `events` in it, in order. +pub fn one_frame(h: &mut Harness<'_, S>, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.step(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn touch(phase: TouchPhase, pos: Pos2) -> Event { + Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None } +} + +/// A finger tap at `p`, or a pen tap on Windows: the touch starts (touch, move, press in one +/// frame), and a frame later it ends (touch, release, `PointerGone` in one frame). +pub fn touch_tap(h: &mut Harness<'_, S>, p: Pos2) { + one_frame(h, [touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, true)]); + h.run_steps(1); + one_frame(h, [touch(TouchPhase::End, p), button(p, false), Event::PointerGone]); + h.run_steps(4); +} + +/// A finger drag from `a` to `b` in six moves, one frame each, ending as a touch does: release +/// and `PointerGone` in one frame. +pub fn touch_drag(h: &mut Harness<'_, S>, a: Pos2, b: Pos2) { + one_frame(h, [touch(TouchPhase::Start, a), Event::PointerMoved(a), button(a, true)]); + for i in 1..=6 { + let p = a + (b - a) * (i as f32 / 6.0); + one_frame(h, [touch(TouchPhase::Move, p), Event::PointerMoved(p)]); + } + one_frame(h, [touch(TouchPhase::End, b), button(b, false), Event::PointerGone]); + h.run_steps(4); +} + +/// A mouse click at `p` whose press and release land in one slow frame (#2100). +pub fn coalesced_click(h: &mut Harness<'_, S>, p: Pos2) { + h.hover_at(p); + h.run_steps(2); + one_frame(h, [button(p, true), button(p, false)]); + h.run_steps(4); +} + +/// A double-click at `p`: two clicks, each a press and release in one frame, in consecutive +/// frames (0.25 s apart, within egui's 0.3 s double-click delay). +pub fn double_click(h: &mut Harness<'_, S>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + one_frame(h, [button(p, true), button(p, false)]); + } + h.run_steps(2); +} diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index 2e1e11ffb2..aa96ab20dc 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -1,7 +1,10 @@ -//! State-based waits for native preparation, which completes between UI frames. +//! State-based waits for native preparation, which completes between UI frames, and pointer +//! input in the frames the platform delivers it in ([`input`]). use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; +pub mod input; + /// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work /// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is /// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between From 3ff8222d72214d177337d522e852d9b977926d41 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 12:13:10 +0200 Subject: [PATCH 27/43] test(ui): page modules read the pointer only through PagePointer A plain #[test] in page_pointer.rs reads the source of the page modules (the per-page chain in canvas::document_area and the modules it calls) and fails on any raw read of egui's pointer state: hover_pos, interact_pos, latest_pos and press_origin, Context's pointer_*_pos shortcuts, and the primary_* and any_* button reads. Its message names the file and line and points to PagePointer. 24 open PRs add such reads to these modules, and three of them (#2137, #1709, #2235) merge cleanly on top of the snapshot, so they would quietly bring back the touch and overlay bugs it fixed. CI's existing cargo test --workspace runs it on Linux and Windows. To pass, the snapshot also carries the button state (primary_down and any_down, global as before), and the page-input button reads that were left use it or the frame's press edge: drag ends in Prepare, redact, the Link tool, added content, Edit text and objects, the comment grab check, a text field opening on the press, and the press that makes a page current. Code in these files that is not page input is listed, each with its reason, in NOT_PAGE_INPUT: the field, paragraph and added-text editors (Areas of their own), the signature dialog's Scan tab, Organize Pages, the page size hint, comment key handling, and four view gates in document_area that run before the pages' widget exists (zoom anchor, drawing edge scroll, glide interruption, full screen's click to turn the page), now small named functions. Prepare a form's calculation order overlay (calc_order_ui) is a page module too, and takes the pages' pointer for its hover. The test also fails on a list entry that reads no pointer state any more, and on a source file that takes PagePointer without being listed as a page module. Seen failing: a raw hover_pos() put back into zoom_snap.rs, and main's raw read put back into the stamp block of document_area, are reported with their lines, as are a stale list entry and crop.rs left off the module list. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/calc_order_ui.rs | 5 +- crates/ui-egui/src/canvas.rs | 48 ++++++-- crates/ui-egui/src/comments.rs | 25 ++-- crates/ui-egui/src/content_ui.rs | 2 +- crates/ui-egui/src/edit_text_ui.rs | 10 +- crates/ui-egui/src/forms_ui.rs | 4 +- crates/ui-egui/src/link_ui.rs | 2 +- crates/ui-egui/src/object_ui.rs | 7 +- crates/ui-egui/src/page_pointer.rs | 176 ++++++++++++++++++++++++++-- crates/ui-egui/src/prepare.rs | 4 +- crates/ui-egui/src/redact_ui.rs | 2 +- 11 files changed, 233 insertions(+), 52 deletions(-) diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index 894fe17929..b9a9fe9e83 100644 --- a/crates/ui-egui/src/calc_order_ui.rs +++ b/crates/ui-egui/src/calc_order_ui.rs @@ -724,10 +724,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { /// The calculation order on page `page`: the links of the field in focus, with the fields it has /// nothing to do with faded, and the steps of the focus and its linked fields; with nothing in /// focus, the steps of the fields whose place is wrong. A form with many calculations stays -/// readable either way. Returns the field under the pointer. +/// readable either way. Returns the field under the pages' `pointer`. #[allow(clippy::too_many_arguments)] pub(crate) fn paint_page( - ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, @@ -736,6 +735,7 @@ pub(crate) fn paint_page( view: &CalcOrderView, generation: u64, selected: Option<&str>, + pointer: Option, ) -> Option { let listed = view.current(generation)?; // Each field's first widget on this page. @@ -749,7 +749,6 @@ pub(crate) fn paint_page( if on_page.is_empty() { return None; } - let pointer = ui.input(|i| i.pointer.hover_pos()); let hovered = pointer.and_then(|p| on_page.iter().rev().find(|(_, r)| r.contains(p))).map(|(n, _)| (*n).to_owned()); let at: HashMap<&str, Rect> = on_page.iter().copied().collect(); let zoom = xf.zoom().clamp(0.5, 3.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 16285f8a98..a933c7acc5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2836,11 +2836,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Full screen: a click on the document goes forward a page and a shift-click back, as in // Acrobat, unless the click did something else (a link, a word selected). Decided below, // once the pages have had the click. - let present_click = view.presentation.is_some() - && navigation_enabled - && ui.rect_contains_pointer(navigation_rect) - && ui.input(|i| i.pointer.primary_clicked()) - && app.quick_tool == QuickTool::Select; + let present_click = + view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select; // Shift as held now, or as the click's own event carried it (a synthetic click). let present_back = present_click && ui.input(|i| { @@ -2851,7 +2848,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { let z = view.zoom * zoom_delta; - match ui.input(|i| i.pointer.hover_pos()) { + match zoom_anchor(ui) { Some(p) => view.zoom_at(z, p), None => view.set_zoom(z), } @@ -2867,7 +2864,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view // that way, so a point beyond what is shown can be reached without letting go of the tool. let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { - let (pointer, dt) = ui.input(|i| (if i.pointer.any_down() { i.pointer.latest_pos() } else { i.pointer.hover_pos() }, i.stable_dt)); + let (pointer, dt) = drawing_edge_pointer(ui); crate::autoscroll::edge_scroll(pointer, avail, dt) } else { Vec2::ZERO @@ -3007,7 +3004,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); - let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); + let interrupted = glide_interrupted(ui); if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { view.fling.stop(); } @@ -3260,7 +3257,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page // gone to earlier that still shows whole beside it. - let pressed = unobstructed && ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(avail); + let pressed = unobstructed && ptr.pressed().is_some(); let mut pressed_page = None; if resp.secondary_clicked() { // Set again below when the right-click lands on a link. @@ -3477,7 +3474,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). if let QuickTool::Fill(ft) = tool && allowed - && !comments::fill_grabs(ui, &pcx, view) + && !comments::fill_grabs(&pcx, view) { match crate::fill_sign::page_input( ui, @@ -3536,7 +3533,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { use crate::forms_ui::FieldPointer; let field = if tool == QuickTool::Select && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) - && !comments::select_grabs(ui, &pcx, view) + && !comments::select_grabs(&pcx, view) { crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) } else { @@ -3851,7 +3848,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if calc_order { let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = crate::calc_order_ui::paint_page(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) { + if let Some(name) = + crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer) + { calc_hover = Some(name); } } @@ -4716,6 +4715,31 @@ fn set_layers(app: &mut PdfCraftApp, index: usize, changes: &[(LayerOp, (u32, u1 } } +/// Where a pinch or ⌘-scroll zooms the view about: the pointer, wherever it is over the view. Not +/// page input: it is read before the pages' widget exists, once `rect_contains_pointer` has found +/// the view under the pointer. +fn zoom_anchor(ui: &egui::Ui) -> Option { + ui.input(|i| i.pointer.hover_pos()) +} + +/// The pointer and frame time that scroll the view while a comment is drawn. Not page input: it is +/// read before the pages' widget exists, and the pointer may be past the view's edge. +fn drawing_edge_pointer(ui: &egui::Ui) -> (Option, f32) { + ui.input(|i| (i.pointer.latest_pos(), i.stable_dt)) +} + +/// A press anywhere, or a key, stops the touchpad's glide (#531). +fn glide_interrupted(ui: &egui::Ui) -> bool { + ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))) +} + +/// Full screen's click on the document, which turns the page: a primary click this frame with the +/// view on top under it in `area`. Not page input: it is read before the pages' widget exists, and +/// the turn waits until the pages have had the click. +fn presentation_click(ui: &egui::Ui, area: Rect) -> bool { + ui.rect_contains_pointer(area) && ui.input(|i| i.pointer.primary_clicked()) +} + /// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855). fn in_size_hint_zone(area: Rect, p: Pos2) -> bool { p.x >= area.min.x && p.x < area.center().x && p.y > area.center().y && p.y <= area.max.y diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 4215bed8c4..c0668b5bb6 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -815,8 +815,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, let page_rect = cx.xf.rect; let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); - let fill_grab = fill_grabs(ui, cx, view); - let draw_grab = draw_grabs(ui, cx, view); + let fill_grab = fill_grabs(cx, view); + let draw_grab = draw_grabs(cx, view); let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None }; let cv = &mut view.comments; if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { @@ -1085,20 +1085,20 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, /// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While /// the button is down the press decides; otherwise the pointer (egui clears the press origin on /// release, so a click is located by the pointer). -pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Fill(_)) && grabs(ui, cx, view, |a| a.fill_sign) +pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { + matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view, |a| a.fill_sign) } /// With the Select tool, a comment that would move (not text markup) is picked up before a form /// field under it, so a signature placed over a field can still be dragged off it. -pub(crate) fn select_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { - cx.tool == QuickTool::Select && grabs(ui, cx, view, |a| !is_markup(&a.subtype)) +pub(crate) fn select_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { + cx.tool == QuickTool::Select && grabs(cx, view, |a| !is_markup(&a.subtype)) } /// With a shape tool (rectangle, oval, line, polygon…), the selected comment's resize handles /// are picked up as with the Select tool, so the shape just drawn can be resized straight away /// (#2338). Only the handles: a press anywhere else, the shape itself included, still draws. -pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { +pub(crate) fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { let QuickTool::Comment(tool) = cx.tool else { return false }; if !(tool.draws() || tool.clicks_points()) || tool == CommentTool::Eraser || cx.hidden { return false; @@ -1111,12 +1111,12 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool if cv.gesture.is_some() { return false; } - press_point(ui, cx).is_some_and(|p| on_handle(cx, view, p, |_| true)) + press_point(cx).is_some_and(|p| on_handle(cx, view, p, |_| true)) } /// Whether the press (or, with no button down, the pointer) is on a `pick` comment or on the /// selected one's resize handles. -fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool { +fn grabs(cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool { if cx.hidden { return false; } @@ -1124,15 +1124,14 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati { return true; } - let Some(p) = press_point(ui, cx) else { return false }; + let Some(p) = press_point(cx) else { return false }; on_handle(cx, view, p, &pick) || cx.hit(p).is_some_and(pick) } /// Where a gesture on this page is decided: the press while the button is down, otherwise the /// pointer (egui clears the press origin on release). `None` off the page. -fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option { - let down = ui.input(|i| i.pointer.any_down()); - (if down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p)) +fn press_point(cx: &PageCx<'_>) -> Option { + (if cx.ptr.any_down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p)) } /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`. diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 013904988a..ed4d0622fb 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -340,7 +340,7 @@ pub(crate) fn page_input( && gp == page { consumed = true; - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { cv.grab = None; let end = pointer.unwrap_or(start); if let Some((sp, si)) = cv.selected diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index ec803cf6e6..d0888ce134 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -560,7 +560,7 @@ fn crop_input( max.x = max.x.clamp(min.x + min_w, b.max.x); max.y = max.y.clamp(min.y + min_h, b.max.y); mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max)); - if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { mode.drag = None; } } @@ -674,7 +674,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointe let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; let Some(rect) = image.visible_rect else { return false }; let b = xf.user_rect(info, page, rect.map(|v| v as f32)); - let p = if ui.input(|i| i.pointer.primary_down()) { ptr.origin } else { ptr.pos }; + let p = if ptr.primary_down { ptr.origin } else { ptr.pos }; let knob = rotate_knob(b, ui.clip_rect()).0; p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0)) } @@ -944,7 +944,7 @@ pub(crate) fn image_input( painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; s.turning = false; @@ -1002,7 +1002,7 @@ pub(crate) fn image_input( painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); handles(preview); } - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; let norm = |r: Rect| { @@ -1272,7 +1272,7 @@ pub(crate) fn page_input( draw_handles(preview); } ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); - if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + if resp.drag_stopped() || !ptr.any_down { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); let edit = if resizing { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 6935eab65a..8182fa668c 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -203,7 +203,7 @@ pub(crate) fn page_input( } // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). - let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()); + let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some(); if pressed && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { commit(view, form); view.forms.focus = Some(Focus { press: Some(p), ..open_focus(f, wi) }); @@ -235,7 +235,7 @@ pub(crate) fn page_input( // The press already committed the previous field and opened this one (above), unless // press and release came in one frame. Committing again would re-run a validation that // refused the value on the press. - FormFieldKind::Text if !ui.input(|i| i.pointer.primary_pressed()) => { + FormFieldKind::Text if ptr.pressed().is_none() => { // An editor opened by the press cannot be hit until a frame has laid it out: the // release lands on the page and would take its focus away. if let Some(x) = view.forms.focus.as_mut().filter(|x| x.name == f.name && x.widget == wi) { diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index 55728194d0..446de39fc0 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -73,7 +73,7 @@ pub(crate) fn page_input( if let Some((dp, start)) = lv.drag && dp == page { - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { lv.drag = None; let end = pointer.unwrap_or(start); let r = Rect::from_two_pos(start, end).intersect(xf.rect); diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index ed39e4b206..8d7d64e47c 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -171,11 +171,10 @@ pub(crate) fn page_input( } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); - let modifier_press = - ui.input(|i| i.pointer.primary_pressed()) && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); + let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); if blocked || modifier_press { view.objects.hold_editor = true; - view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page); + view.objects.blocked_page = ptr.primary_down.then_some(page); if modifier_press { view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); } @@ -201,7 +200,7 @@ fn input( view: &mut DocView, ) -> bool { let pointer = ptr.pos; - let (pressed, down) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); + let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down); let info = &doc.info; if s.page == Some(page) && let Some(mut gesture) = s.gesture diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index 6466f2a877..df373c1547 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -4,8 +4,8 @@ //! chrome over a page scrolled under it) and keeps a press with the widget it began on until //! release. egui's raw pointer state knows neither, and its hover position is gone on the frame //! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and -//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never -//! `hover_pos`, `latest_pos` or `interact_pos`. +//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's +//! pointer positions or button states; a test below keeps it that way. use egui::{Event, PointerButton, Pos2, Rect}; @@ -33,12 +33,17 @@ pub(crate) struct PagePointer { pub origin: Option, /// This frame's primary presses and releases of a press the pages hold, in order. pub edges: Vec, + /// The primary button is down, wherever it was pressed: a gesture ends once it is up. + pub primary_down: bool, + /// Some button is down, wherever it was pressed. + pub any_down: bool, } impl PagePointer { /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`. pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response) -> Self { - let (at, origin, edges) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events))); + let (at, origin, edges, primary_down, any_down) = + ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down())); // egui's own answer to "do the pages hold a press?": from the press on until the frame // of its click, release or drag stop. A drag can outlive egui's press while the // pointer is outside the window. @@ -49,9 +54,9 @@ impl PagePointer { let on_top = |p: &Pos2| resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); let pos = at.filter(|p| held || on_top(p)); if !held { - return Self { pos, origin: None, edges: Vec::new() }; + return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; } - Self { pos, origin: origin.or_else(|| first_press(&edges)), edges } + Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down } } /// Where the pointer is, when it is inside `r`. @@ -164,10 +169,10 @@ mod tests { } // Off the pages altogether. assert_eq!(frame(&ctx, vec![Event::PointerMoved(pos2(300.0, 50.0))], true).pos, None); - // A press on the Area is not the pages' press. + // A press on the Area is not the pages' press, though the button is down all the same. let p = pos2(160.0, 160.0); let press = frame(&ctx, vec![button(p, PointerButton::Primary, true)], true); - assert_eq!(press, PagePointer::default()); + assert_eq!(press, PagePointer { primary_down: true, any_down: true, ..PagePointer::default() }); } #[test] @@ -180,14 +185,24 @@ mod tests { // Dragged over the Area and off the pages: still theirs. for p in [pos2(160.0, 160.0), pos2(300.0, 50.0)] { let held = frame(&ctx, vec![Event::PointerMoved(p)], true); - assert_eq!((held.pos, held.origin), (Some(p), Some(start)), "{p:?}"); + assert_eq!((held.pos, held.origin, held.primary_down), (Some(p), Some(start), true), "{p:?}"); } let end = pos2(300.0, 60.0); let up = frame(&ctx, vec![Event::PointerMoved(end), button(end, PointerButton::Primary, false)], true); assert_eq!((up.pos, up.edges.as_slice()), (Some(end), [Edge::Release(end)].as_slice()), "the release is the pages' too"); + assert!(!up.primary_down && !up.any_down, "and the button is up"); assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs"); } + #[test] + fn a_secondary_press_is_some_button_down_but_no_primary_edge() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let right = frame(&ctx, vec![button(p, PointerButton::Secondary, true)], false); + assert_eq!((right.pos, right.any_down, right.primary_down, right.edges.len()), (Some(p), true, false, 0)); + } + #[test] fn edges_are_primary_finite_in_order_and_capped() { let p = pos2(1.0, 2.0); @@ -204,4 +219,149 @@ mod tests { assert_eq!(edges(&many).len(), MAX_EDGES); assert_eq!(first_press(&[Edge::Release(q), Edge::Press(p), Edge::Press(q)]), Some(p)); } + + /// The page modules: the per-page chain in `canvas::document_area` and what it calls. + const PAGE_MODULES: [&str; 18] = [ + "canvas.rs", + "comments.rs", + "forms_ui.rs", + "prepare.rs", + "fill_sign.rs", + "sign_ui.rs", + "crop.rs", + "zoom_snap.rs", + "camera_ui.rs", + "measure_ui.rs", + "redact_ui.rs", + "link_ui.rs", + "content_ui.rs", + "edit_text_ui.rs", + "object_ui.rs", + "image_pick.rs", + "signature_drag.rs", + "calc_order_ui.rs", + ]; + + /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. + const RAW_READS: [&str; 15] = [ + "hover_pos()", + "interact_pos()", + "latest_pos()", + "press_origin()", + "pointer_hover_pos()", + "pointer_interact_pos()", + "pointer_latest_pos()", + "primary_down()", + "primary_pressed()", + "primary_released()", + "primary_clicked()", + "any_down()", + "any_pressed()", + "any_released()", + "any_click()", + ]; + + /// Functions in the page modules that are not page input and read egui's pointer state + /// themselves: (file, function, why). + const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [ + ("canvas.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), + ("canvas.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), + ("canvas.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), + ("canvas.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), + ("canvas.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), + ("canvas.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), + ("comments.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"), + ("forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), + ("content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), + ("edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), + ("fill_sign.rs", "scan_tab", "the signature dialog's Scan tab"), + ]; + + /// The function a `fn` line declares, laid out as rustfmt does (`pub`, `const` and the like + /// before `fn`). + fn declares(line: &str) -> Option<&str> { + let mut rest = line; + loop { + let before = rest; + for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] { + rest = rest.strip_prefix(prefix).unwrap_or(rest); + } + if rest == before { + break; + } + } + let rest = rest.strip_prefix("fn ")?; + rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end]) + } + + /// Whether `line` calls `read`, and not a longer name that ends in it. + fn reads(line: &str, read: &str) -> bool { + line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) + } + + /// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is + /// drawn over the pages and where a touch lifted, so a raw read in a page module brings back + /// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing. + #[test] + fn page_modules_read_the_pointer_only_through_page_pointer() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut problems = Vec::new(); + let mut needed = vec![false; NOT_PAGE_INPUT.len()]; + for file in PAGE_MODULES { + let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); + // Tests drive egui's input themselves. + let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); + // The top-level function, or method of an `impl`, that each line is in. + let (mut function, mut in_impl) = (None, false); + for (n, line) in code.lines().enumerate() { + if line.starts_with("impl") { + (function, in_impl) = (None, true); + } else if line == "}" { + (function, in_impl) = (None, false); + } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { + function = Some(name); + } + if line.trim_start().starts_with("//") { + continue; + } + for read in RAW_READS.into_iter().filter(|read| reads(line, read)) { + match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) { + Some(k) => needed[k] = true, + None => problems.push(format!( + "src/{file}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \ + (src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \ + lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.", + n + 1 + )), + } + } + } + } + for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) { + if !needed { + problems.push(format!("src/{file}: fn {name} reads no pointer state any more: remove it from NOT_PAGE_INPUT.")); + } + } + // A module that takes the page pointer is a page module, so it is checked too. + let mut dirs = vec![src.clone()]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).expect("source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + dirs.push(path); + continue; + } + if path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + let file = + path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + let source = std::fs::read_to_string(&path).expect("source file"); + if file != "page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) { + problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES.")); + } + } + } + assert!(problems.is_empty(), "{}", problems.join("\n")); + } } diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 34a5566347..9f58f5ea5e 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -261,7 +261,7 @@ pub(crate) fn page_input( return out; } if prep.ignore_release { - if !ui.input(|i| i.pointer.primary_down()) { + if !ptr.primary_down { prep.ignore_release = false; } out.consumed = true; @@ -273,7 +273,7 @@ pub(crate) fn page_input( && gp == page { out.consumed = true; - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { prep.grab = None; let end = pointer.unwrap_or(start); match grab { diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 4058ae695e..8a48b3f690 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -250,7 +250,7 @@ pub(crate) fn page_input( if let Some((dp, start)) = view.redact_drag && dp == page { - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { view.redact_drag = None; let end = pointer.unwrap_or(start); let r = Rect::from_two_pos(start, end).intersect(xf.rect); From 1bda33975de6342235e98c76a5f6c4356639f40c Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 15 Oct 2026 22:48:18 +0200 Subject: [PATCH 28/43] docs: page modules read PagePointer, never egui's raw pointer state A one-line non-negotiable in CLAUDE.md, next to Layering, so agents and contributors know the rule before the test tells them: page modules read the pointer through PagePointer (crates/ui-egui/src/page_pointer.rs), never hover_pos, interact_pos, latest_pos, press_origin or the button reads. It names the test that enforces it, page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 085e8356f6..f51a96d56e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - See `plan/README.md` §Clean-room and `plan/adr/0001`. - **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data. - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. +- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. From 9f2e1e32934acde8ca3637e9ac20e5d4ebee8976 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:33:30 +0200 Subject: [PATCH 29/43] fix(ui): a tap on an open field or paragraph editor is not a click away The form field editors and the Edit text paragraph editor are drawn over the pages, outside the page pointer, and close on a click away. With a finger, or a pen on Windows (winit reports it as touch), they closed on taps inside them: - A tap on a combo box opened its list and closed it in the same frame, so a choice could not be picked by touch. - A tap on a list box row picked it, but the list lost focus, so the arrow keys no longer moved the choice. - A tap on a date field's calendar arrows closed the field. - With a paragraph open in Edit text, a tap on a Format text control that only opens a list, such as Line spacing, applied and closed the paragraph, and the list went with it. Two things were wrong. The combo and list boxes judged the click by hover_pos, and the paragraph's "outside the document" by latest_pos. Both are None on the frame egui reports a tap, since egui-winit sends PointerGone with every touch release. They now read interact_pos, which keeps the tap's position on that frame (egui's own clicked_elsewhere does the same). And egui 0.36 reports a lost focus for two frames. The text field editor and the paragraph editor judged their exceptions again on the second frame, when a tap has no position left and a mouse may have moved on. They now judge a loss once, on its own frame (edit_text_ui::lost_focus_once, with a flag kept per editor). A focus taken later in a frame is still seen on the next one, as before. Tests: tests/it/editor_taps.rs taps with support::input::touch_tap, which sends the release and PointerGone in one frame as egui-winit does, and pairs each tap with a mouse click as the control. That click leaves the pointer in the window: kittest's drop_at sends PointerGone a frame later, which the second-frame judging also turned into a click away. With the source reverted, the four tap tests fail at their bug assertion and the controls pass. Reverting one part at a time fails only its own tests: hover_pos in the combo and list check, egui's lost_focus in the field editor (the calendar) or in the paragraph editor, and latest_pos for the paragraph's outside. A tap inside an open text field already worked and has a guard test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_text_ui.rs | 23 ++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/forms_ui.rs | 18 +- crates/ui-egui/tests/it/editor_taps.rs | 276 +++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 5 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/it/editor_taps.rs diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d0888ce134..f67398d84f 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -246,6 +246,9 @@ pub struct LineEditor { size: f32, /// Take the keyboard focus on the next frame. pub(crate) focus: bool, + /// Whether the text box had the keyboard at the end of the editor's last frame + /// ([`lost_focus_once`]). + had_focus: bool, /// The Format text panel's values, and what the paragraph had (to send only changes). pub look: pdfcraft_engine::AddedText, look0: pdfcraft_engine::AddedText, @@ -1196,6 +1199,7 @@ fn open_editor( max_width, size: (l.size as f32 * scale).clamp(8.0, 72.0), focus: true, + had_focus: false, look: look_of(l), look0: look_of(l), family_chosen: false, @@ -1556,8 +1560,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { - // Clicks outside the document (the Format text panel) keep the paragraph open. - let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); + // Clicks outside the document (the Format text panel) keep the paragraph open. Where the + // click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts. + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); let page = view.line_editor.as_ref()?.page; let xf = view.page_xform(page)?; let hold = view.objects.hold_editor; @@ -1703,11 +1708,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - r.request_focus(); ed.focus = false; } + let lost = lost_focus_once(&r, &mut ed.had_focus); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); if esc { done = Some(false); - } else if apply || (r.lost_focus() && !outside && !hold) { + } else if apply || (lost && !outside && !hold) { done = Some(true); } }); @@ -1742,6 +1748,16 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option { }) } +/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at +/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second +/// frame as well, and an editor that judges a click away by where the click landed must judge it +/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends +/// `PointerGone` with every touch release) and a mouse may have moved on. +pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { + let has = r.has_focus(); + std::mem::replace(had, has) && !has +} + /// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. pub(crate) fn caret_color_on(fill: Color32) -> Color32 { let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); @@ -1799,6 +1815,7 @@ mod tests { max_width: 400.0, size: block.size as f32, focus: true, + had_focus: false, look: look_of(&block), look0: look_of(&block), family_chosen: false, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 1e9c0d6b7b..edbb56c2c9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1609,6 +1609,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8182fa668c..601061af04 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -43,6 +43,9 @@ pub struct Focus { /// The text the calendar last saw, so it moves to a typed date once, when the typing becomes /// a whole date, and otherwise keeps the month its arrows turned to. pub calendar_text: Option, + /// Whether the text box had the keyboard at the end of the editor's last frame + /// ([`crate::edit_text_ui::lost_focus_once`]). + pub(crate) had_focus: bool, } #[derive(Clone, Debug, Default)] @@ -121,6 +124,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } @@ -652,6 +656,10 @@ pub(crate) fn overlay( return None; }; let rect = widget_rect(&xf, info, page, w.rect); + // Whether a click this frame landed outside the field. Where it landed is `interact_pos`: a + // tap's release comes with `PointerGone` (egui-winit sends one with every touch release, and + // winit reports Windows pen input as touch), which clears `hover_pos`. + let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); let zoom = xf.zoom(); let t = Tokens::get(ctx); let mut edit = None; @@ -738,6 +746,7 @@ pub(crate) fn overlay( st.store(ui.ctx(), r.id); } } + let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) }); @@ -745,7 +754,7 @@ pub(crate) fn overlay( close = true; } else if tab { next = Some(!shift); - } else if r.lost_focus() + } else if lost && (!multiline || !enter) && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) { @@ -793,9 +802,7 @@ pub(crate) fn overlay( }); }); }); - let clicked_away = ctx.input(|i| i.pointer.any_click()) - && !resp.response.contains_pointer() - && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); + let clicked_away = !resp.response.contains_pointer() && clicked_outside(); let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift)); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; @@ -829,8 +836,7 @@ pub(crate) fn overlay( edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) }); } } - let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); - if clicked_away { + if clicked_outside() { close = true; } } diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs new file mode 100644 index 0000000000..68d0a78939 --- /dev/null +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -0,0 +1,276 @@ +//! A finger tap, or a pen tap on Windows, on an editor open over the page acts as a mouse click +//! there. The field editors and the Edit text paragraph editor are drawn over the pages, outside +//! the page pointer, and close on a click away. On the frame egui reports a tap there is no +//! hover position (egui-winit sends `PointerGone` with every touch release), so they must judge +//! where the click landed by the click's own position. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +use crate::support::input::touch_tap; + +/// A PDF of `objects`, numbered from 1 with the catalog first. +fn pdf(objects: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>"; +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + +/// A 300×400 page with a text field `name`, a combo box `country` and a list box `colour` +/// (Red, Green, Blue) 70 pt tall. +fn form_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [40 340 260 360] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (country) /Opt [[(ca) (Canada)] [(fr) (France)] [(no) (Norway)]] /Rect [40 290 260 310] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (colour) /Opt [(Red) (Green) (Blue)] /Rect [40 150 200 220] /P 3 0 R /F 4 >>", + HELVETICA, + ]) +} + +/// A 300×400 page with one line of 12 pt text at x 40, y 300. +fn text_pdf() -> Vec { + let line = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET"; + let content = format!("<< /Length {} >>\nstream\n{line}\nendstream", line.len() + 1); + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + content.as_str(), + HELVETICA, + ]) +} + +/// The app with `pdf` open at 150 % and its first frames run. +fn open(pdf: Vec) -> Harness<'static, PdfCraftApp> { + 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("test.pdf", None, pdf).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + h +} + +/// A mouse click: move, press and release in frames of their own, and the pointer stays there. +/// (`drop_at` also sends `PointerGone` a frame later, as if the mouse left the window.) +fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); +} + +/// Where field `name`'s widget is on screen. +fn field(h: &Harness<'_, PdfCraftApp>, name: &str) -> egui::Rect { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") +} + +fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +/// The field whose editor or list is open. +fn focus(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone()) +} + +/// The Green row of the list box: the second, rows being 11.5 pt under a 2 pt inset. +fn green(h: &Harness<'_, PdfCraftApp>) -> egui::Pos2 { + let r = field(h, "colour"); + egui::pos2(r.center().x, r.min.y + 19.0 * r.height() / 70.0) +} + +// --------------------------------------------------------------------------------------------- +// Form fields. + +#[test] +fn mouse_click_opens_a_combo_box() { + // Control for the tap below. + let mut h = open(form_pdf()); + let p = field(&h, "country").center(); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("country"), "a click opens the list of choices"); +} + +#[test] +fn touch_tap_opens_a_combo_box_and_picks_a_choice() { + let mut h = open(form_pdf()); + let p = field(&h, "country").center(); + touch_tap(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("country"), "a tap opens the list of choices, as a click does"); + let france = h.get_by_label("France").rect().center(); + touch_tap(&mut h, france); + assert_eq!(value(&h, "country"), ["fr"], "and a tap on a choice picks it"); +} + +#[test] +fn mouse_click_in_a_list_box_keeps_it_focused() { + // Control for the tap below. + let mut h = open(form_pdf()); + let p = green(&h); + mouse_click(&mut h, p); + assert_eq!((value(&h, "colour"), focus(&h).as_deref()), (vec!["Green".to_string()], Some("colour")), "a click picks the row"); + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(value(&h, "colour"), ["Blue"], "and the list keeps the keyboard"); +} + +#[test] +fn touch_tap_in_a_list_box_keeps_it_focused() { + let mut h = open(form_pdf()); + let p = green(&h); + touch_tap(&mut h, p); + assert_eq!( + (value(&h, "colour"), focus(&h).as_deref()), + (vec!["Green".to_string()], Some("colour")), + "a tap picks the row and keeps the list focused, as a click does" + ); + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(value(&h, "colour"), ["Blue"], "so the arrow keys go on from it"); +} + +#[test] +fn touch_tap_inside_an_open_text_field_keeps_typing_there() { + // A guard more than a probe: the text editor closes when egui takes its focus away, and + // egui judges that from the tap's own position. + let mut h = open(form_pdf()); + let r = field(&h, "name"); + touch_tap(&mut h, r.center()); + assert_eq!(focus(&h).as_deref(), Some("name"), "a tap opens the field for typing"); + touch_tap(&mut h, r.left_center() + egui::vec2(6.0, 0.0)); + assert_eq!(focus(&h).as_deref(), Some("name"), "a tap inside the open field keeps it open"); + h.event(egui::Event::Text("Ada".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "name"), ["Ada"], "and the typing lands in it"); +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Adds a date field `due` at x 40..160, y 240..260, its calendar under it. +fn add_date_field(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [40.0, 240.0, 160.0, 260.0], + kind: pdfcraft_engine::NewField::Date, + name: Some("due".into()), + }); + h.run_steps(4); +} + +/// Next month's calendar heading, and its 15th as the date field writes it. +fn next_month(h: &Harness<'_, PdfCraftApp>) -> (String, String) { + let (y, m, _) = h.state().session.today(); + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + (format!("{} {ny}", MONTHS[(nm - 1) as usize]), format!("{nm:02}/15/{ny}")) +} + +#[test] +fn mouse_click_on_a_date_fields_calendar_keeps_it_open() { + // Control for the tap below. + let mut h = open(form_pdf()); + add_date_field(&mut h); + let (heading, picked) = next_month(&h); + let p = field(&h, "due").center(); + mouse_click(&mut h, p); + let next = h.get_by_label("›").rect().center(); + mouse_click(&mut h, next); + assert_eq!(focus(&h).as_deref(), Some("due"), "a click on the calendar's next month leaves the field open"); + h.get_by_label(&heading); + let day = h.get_by_label("15").rect().center(); + mouse_click(&mut h, day); + assert_eq!(value(&h, "due"), [picked], "and a click on a day picks it"); +} + +#[test] +fn touch_tap_on_a_date_fields_calendar_keeps_it_open() { + let mut h = open(form_pdf()); + add_date_field(&mut h); + let (heading, picked) = next_month(&h); + let p = field(&h, "due").center(); + touch_tap(&mut h, p); + let next = h.get_by_label("›").rect().center(); + touch_tap(&mut h, next); + assert_eq!(focus(&h).as_deref(), Some("due"), "a tap on the calendar's next month leaves the field open, as a click does"); + h.get_by_label(&heading); + let day = h.get_by_label("15").rect().center(); + touch_tap(&mut h, day); + assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it"); +} + +// --------------------------------------------------------------------------------------------- +// Edit text: a click on the Format text panel keeps the paragraph open. These open the Line +// spacing list: a control that changes the paragraph on the click gives it the keyboard back in +// that same frame, which hides a click away. + +/// Opens the line of `text_pdf` for typing with Edit text. +fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("page on screen"); + let k = r.width() / 300.0; + let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k); + mouse_click(h, p); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("edit-text-line-input"))), "with the keyboard"); +} + +/// The open paragraph's line spacing, if a paragraph is open. +fn line_spacing(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().views[0].line_editor.as_ref().map(|e| e.extras.line_spacing) +} + +#[test] +fn mouse_click_on_format_text_keeps_the_paragraph_open() { + // Control for the tap below. + let mut h = open(text_pdf()); + edit_paragraph(&mut h); + let spacing = h.get_by_value("Auto").rect().center(); + mouse_click(&mut h, spacing); + assert_eq!(line_spacing(&h), Some(0.0), "a click opens the Line spacing list and leaves the paragraph open"); + let double = h.get_by_label("2").rect().center(); + mouse_click(&mut h, double); + assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked"); +} + +#[test] +fn touch_tap_on_format_text_keeps_the_paragraph_open() { + let mut h = open(text_pdf()); + edit_paragraph(&mut h); + let spacing = h.get_by_value("Auto").rect().center(); + touch_tap(&mut h, spacing); + assert_eq!(line_spacing(&h), Some(0.0), "a tap opens the Line spacing list and leaves the paragraph open, as a click does"); + let double = h.get_by_label("2").rect().center(); + touch_tap(&mut h, double); + assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 39807445b5..ca96e8bba7 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -39,6 +39,7 @@ mod date_format; mod dialog_chrome; mod document_links; mod editing; +mod editor_taps; mod export; mod fill_sign; mod folders; From 80e290acd177e072ee72148e94fbdbdd86422378 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 13:21:48 +0200 Subject: [PATCH 30/43] fix(ui): a tap on a focused check box or radio button keeps its focus A click on a check box or radio button focuses it, so Tab, Space and the arrows reach it (#2223). The page hands the click to the field, and the field overlay toggles the box in the same frame unless the click landed elsewhere. That check read hover_pos, which is None on the frame egui reports a tap: egui-winit sends PointerGone with every touch release, and winit reports Windows pen input as touch. So a finger or pen tap counted as a click away. The focus closed and the toggle was dropped: a tap left a check box unticked and unfocused, and a tap on a radio button selected nothing. The check now uses the overlay's clicked_outside, which reads interact_pos, as the other field editors do. Tests: touch_tap_ticks_a_check_box runs again. tests/it/editor_taps.rs taps a focused check box and the other button of a focused radio group, each with a mouse click as the control. Each checks that the focus stays and the tap toggles, and for the radio group that the arrows still move within it. With the fix reverted, the three tap tests fail and the controls pass. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/forms_ui.rs | 3 +- crates/ui-egui/tests/it/editor_taps.rs | 73 +++++++++++++++++++++++++- crates/ui-egui/tests/it/pointer.rs | 1 - 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 601061af04..8caa44ced2 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -873,8 +873,7 @@ pub(crate) fn overlay( None } }); - let clicked_away = ctx.input(|i| i.pointer.any_click() && !i.pointer.hover_pos().is_some_and(|p| rect.contains(p))); - if esc || clicked_away { + if esc || clicked_outside() { close = true; } else if tab { next = Some(!shift); diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs index 68d0a78939..2f35696611 100644 --- a/crates/ui-egui/tests/it/editor_taps.rs +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -86,10 +86,15 @@ fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { /// Where field `name`'s widget is on screen. fn field(h: &Harness<'_, PdfCraftApp>, name: &str) -> egui::Rect { + widget(h, name, 0) +} + +/// Where widget `w` of field `name` is on screen. +fn widget(h: &Harness<'_, PdfCraftApp>, name: &str, w: usize) -> egui::Rect { let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); let f = doc.form.iter().find(|f| f.name == name).unwrap(); - pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, w).expect("on screen") } fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec { @@ -228,6 +233,72 @@ fn touch_tap_on_a_date_fields_calendar_keeps_it_open() { assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it"); } +/// A 300×400 page with a check box `agree` and a pair of radio buttons `pick`, A and then B to +/// its right, each 20 pt square. A click focuses them, so the keys reach them (#2223). +fn toggles_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [40 300 60 320] /P 3 0 R /F 4 /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>", + "<< /FT /Btn /T (pick) /Ff 32768 /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 240 60 260] /P 3 0 R /F 4 /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 240 120 260] /P 3 0 R /F 4 /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream", + ]) +} + +#[test] +fn mouse_click_on_a_focused_check_box_keeps_its_focus() { + // Control for the tap below. + let mut h = open(toggles_pdf()); + let p = field(&h, "agree").center(); + mouse_click(&mut h, p); + assert_eq!((value(&h, "agree"), focus(&h).as_deref()), (vec!["Yes".to_string()], Some("agree")), "a click ticks and focuses the box"); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a click on the focused box keeps its focus"); + assert!(value(&h, "agree").is_empty(), "and clears it"); +} + +#[test] +fn touch_tap_on_a_focused_check_box_keeps_its_focus() { + let mut h = open(toggles_pdf()); + let p = field(&h, "agree").center(); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a click focuses the box"); + touch_tap(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a tap on the focused box keeps its focus, as a click does"); + assert!(value(&h, "agree").is_empty(), "and clears it"); +} + +#[test] +fn mouse_click_on_a_radio_button_keeps_the_group_focused() { + // Control for the tap below. + let mut h = open(toggles_pdf()); + let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center()); + mouse_click(&mut h, a); + assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["A".to_string()], Some("pick")), "a click selects A and focuses the group"); + mouse_click(&mut h, b); + assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["B".to_string()], Some("pick")), "a click on B selects it in the focused group"); + h.key_press(egui::Key::ArrowLeft); + h.run_steps(3); + assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group"); +} + +#[test] +fn touch_tap_on_a_radio_button_keeps_the_group_focused() { + let mut h = open(toggles_pdf()); + let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center()); + mouse_click(&mut h, a); + assert_eq!(focus(&h).as_deref(), Some("pick"), "a click focuses the group"); + touch_tap(&mut h, b); + assert_eq!(focus(&h).as_deref(), Some("pick"), "a tap on B keeps the group focused, as a click does"); + assert_eq!(value(&h, "pick"), ["B"], "and selects B"); + h.key_press(egui::Key::ArrowLeft); + h.run_steps(3); + assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group"); +} + // --------------------------------------------------------------------------------------------- // Edit text: a click on the Format text panel keeps the paragraph open. These open the Line // spacing list: a control that changes the paragraph on the click gives it the keyboard back in diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 73af2373ce..9562353ac4 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -482,7 +482,6 @@ fn mouse_click_ticks_a_check_box() { } #[test] -#[ignore = "the check box focus (#2223) counts a tap as a click away: fixed by a later commit"] fn touch_tap_ticks_a_check_box() { let mut h = open(checkbox_pdf(), "150", false); let p = at(&h, 110.0, 210.0); From 866de03fd80a984417f51a9f79a7215e737feccb Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 14:02:17 +0200 Subject: [PATCH 31/43] fix(ui): a dialog covers the pages from the frame it opens The page pointer has the pointer only where egui has the pages on top, and egui knows what is drawn over them from the layers it met in the frame before. Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet. For that frame the pages kept the pointer, and a press that landed then was theirs: a link's tip showed over the dialog, and a click placed a stamp or followed a link under it. main's page_covered had kept the link's tip and click off the page while a dialog was open, from a hand-kept list. PagePointer::read now also takes whether a dialog is open: PdfCraftApp::modal_open, the list the page press and the edge scroll already check, the Unlock prompt included. With one open the pages see no pointer and no press, except a drag they already have, which goes on to its release. The palette is not a dialog and the page beside it keeps its tools, so it is left to egui's layers, from the frame after it opens. Tests: tests/it/pointer.rs hovers a link and opens the Unlock prompt (no tip in that frame), and clicks with the Stamp tool in the frame the prompt opens (no stamp). A page_pointer unit test hides the pointer and a click under a dialog, and keeps a drag the pages have until its release. With the fix reverted, all three fail. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 9 +++--- crates/ui-egui/src/page_pointer.rs | 50 ++++++++++++++++++++++++------ crates/ui-egui/tests/it/pointer.rs | 49 ++++++++++++++++++++++++----- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a933c7acc5..8a1aa266e9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2792,7 +2792,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } // No dialog, close prompt or palette over the page: only then does page input count. - let unobstructed = !app.modal_open() && !app.palette_open; + let modal = app.modal_open(); + let unobstructed = !modal && !app.palette_open; let organize_zoom = app.organize_zoom; let view = &mut app.views[index]; if view.organize { @@ -3250,9 +3251,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut best_overlap = -1.0f32; let mut current_overlap = -1.0f32; let mut current_height = f32::INFINITY; - // The pointer as the pages see it: nothing over them, or a press they hold. The page - // modules read it rather than egui's raw pointer state. - let ptr = PagePointer::read(ui, &resp); + // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), + // or a press they hold. The page modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp, modal); let pointer = ptr.pos; // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index df373c1547..baf0e29613 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -6,6 +6,9 @@ //! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and //! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's //! pointer positions or button states; a test below keeps it that way. +//! +//! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet: +//! the reading also takes whether one is open. use egui::{Event, PointerButton, Pos2, Rect}; @@ -24,8 +27,9 @@ const MAX_EDGES: usize = 8; #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct PagePointer { /// Where the pointer is, while the pages are what egui has under it, or while they hold a - /// press (until the frame of its release, past a page's edge included). It is egui's - /// `interact_pos`, which still has the position on the frame a touch lifts. + /// press (until the frame of its release, past a page's edge included). With a dialog open + /// only a drag the pages already have keeps it. It is egui's `interact_pos`, which still has + /// the position on the frame a touch lifts. pub pos: Option, /// Where the press the pages hold began, also on the frame of its release when the press /// came in that same frame (egui has forgotten it by then). `None` for a press that began on @@ -40,18 +44,22 @@ pub(crate) struct PagePointer { } impl PagePointer { - /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`. - pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response) -> Self { + /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while + /// a dialog is open over the window or not (`modal`). + pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self { let (at, origin, edges, primary_down, any_down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down())); // egui's own answer to "do the pages hold a press?": from the press on until the frame // of its click, release or drag stop. A drag can outlive egui's press while the - // pointer is outside the window. - let held = resp.dragged() || resp.interact_pointer_pos().is_some(); + // pointer is outside the window. With a dialog open only a drag counts, up to the frame + // it stops: on the frame one opens egui still gives the pages a press that lands under + // it, and its click. + let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some()); // `contains_pointer` alone also holds a few points under the edge of an Area over the - // pages (egui's interact radius), so the top layer there must be the pages' own. + // pages (egui's interact radius), so the top layer there must be the pages' own. A + // dialog covers them from the frame it opens, before egui knows its layer. let layer = ui.layer_id(); - let on_top = |p: &Pos2| resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); + let on_top = |p: &Pos2| !modal && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); let pos = at.filter(|p| held || on_top(p)); if !held { return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; @@ -112,11 +120,16 @@ mod tests { /// Runs one frame of the pages' widget (and the Area over it, with `area`) and returns what /// the pages see. fn frame(ctx: &Context, events: Vec, area: bool) -> PagePointer { + frame_with(ctx, events, area, false) + } + + /// [`frame`], with a dialog open (`modal`) whose layer egui has not met. + fn frame_with(ctx: &Context, events: Vec, area: bool, modal: bool) -> PagePointer { let mut seen = PagePointer::default(); let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag()); - seen = PagePointer::read(ui, &resp); + seen = PagePointer::read(ui, &resp, modal); if area { egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { ui.allocate_exact_size(OVER.size(), Sense::hover()); @@ -194,6 +207,25 @@ mod tests { assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs"); } + #[test] + fn a_dialog_hides_the_pointer_and_its_clicks_but_not_a_drag_the_pages_have() { + let ctx = Context::default(); + let p = pos2(50.0, 50.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + assert_eq!(frame(&ctx, vec![], false).pos, Some(p)); + assert_eq!(frame_with(&ctx, vec![], false, true).pos, None, "no pointer under a dialog, before egui knows its layer too"); + let click = frame_with(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false, true); + assert_eq!((click.pos, click.origin, click.edges.as_slice()), (None, None, [].as_slice()), "nor a click egui gives the pages then"); + // A drag the pages already have when a dialog opens goes on until its release. + frame(&ctx, vec![button(p, PointerButton::Primary, true)], false); + frame(&ctx, vec![Event::PointerMoved(pos2(80.0, 50.0))], false); + let q = pos2(110.0, 50.0); + let dragging = frame_with(&ctx, vec![Event::PointerMoved(q)], false, true); + assert_eq!((dragging.pos, dragging.origin), (Some(q), Some(p)), "a drag the pages have keeps its pointer under a dialog"); + let up = frame_with(&ctx, vec![button(q, PointerButton::Primary, false)], false, true); + assert_eq!(up.edges.as_slice(), [Edge::Release(q)].as_slice(), "and its release"); + } + #[test] fn a_secondary_press_is_some_button_down_but_no_primary_edge() { let ctx = Context::default(); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 9562353ac4..282f53dc02 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -11,7 +11,7 @@ use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; -use crate::support::input::{coalesced_click, double_click, touch_drag, touch_tap}; +use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; /// A PDF of `objects`, numbered from 1 with the catalog first. fn pdf(objects: &[&str]) -> Vec { @@ -358,12 +358,20 @@ fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() { assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair); } +/// Whether the page's hover tip was drawn in the last frame. +fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool { + let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); + h.ctx.memory(|m| m.areas().is_visible(&tip)) +} + +/// Opens the Unlock prompt, a dialog with a backdrop over the whole window, from the next frame. +fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) { + let doc = h.state().views[0].id; + h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); +} + #[test] fn link_tip_stays_under_the_unlock_prompt() { - let tip_shown = |h: &Harness<'_, PdfCraftApp>| { - let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); - h.ctx.memory(|m| m.areas().is_visible(&tip)) - }; let mut h = open(link_pdf(), "300", true); let page = page(&h); let k = page.width() / 300.0; @@ -373,8 +381,7 @@ fn link_tip_stays_under_the_unlock_prompt() { h.run_steps(2); assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); - let doc = h.state().views[0].id; - h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); + open_unlock_prompt(&mut h); h.hover_at(on_link + egui::vec2(1.0, 0.0)); for _ in 0..4 { h.run_steps(2); @@ -399,6 +406,34 @@ fn the_link_tip_lets_the_pointer_through_to_the_link() { assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link"); } +// Dialogs are drawn after the pages, so on the frame one opens egui does not know its layer yet +// and still gives the pages the pointer, and a press there. + +#[test] +fn the_link_tip_stays_under_a_dialog_on_the_frame_it_opens() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + open_unlock_prompt(&mut h); + h.run_steps(1); + assert!(!tip_shown(&h), "the link's tip must not show over the Unlock prompt on the frame it opens"); +} + +#[test] +fn a_click_on_the_frame_a_dialog_opens_places_no_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair); + open_unlock_prompt(&mut h); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [button(true), button(false)]); + h.run_steps(4); + assert_eq!(stamps(&h), 0, "a click in the frame the Unlock prompt opens places no stamp under it"); +} + #[test] fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() { let mut h = open(text_pdf(), "150", false); From a7801dc84e74dd12757aecbb6be324dce7a33939 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:25:44 -0400 Subject: [PATCH 32/43] fix(ui): text typed into a new text box counts as unsaved work (#4441) * fix(ui): text typed into a new text box counts as unsaved work Closing a tab or quitting while the only unsaved thing was text in a new Add text box skipped the save prompt and lost the text. The box now counts like fill text does: only when it holds non-blank text. * fix(ui): saving on close keeps text typed into a new text box --- crates/ui-egui/src/editing.rs | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 1e9c0d6b7b..4f25d6df21 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -38,6 +38,11 @@ pub enum SaveTarget { As, } +/// Text typed into a new text box that isn't added to the page yet. +fn new_text_typed(v: &crate::DocView) -> bool { + v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) +} + impl PdfCraftApp { /// Apply an edit to the active document. Returns `true` on success; failures are shown. pub fn apply_edit(&mut self, edit: Edit) -> bool { @@ -636,6 +641,7 @@ impl PdfCraftApp { || v.content.blocked().is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) + || new_text_typed(v) || v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text()) } @@ -1694,6 +1700,56 @@ mod revert_draft_tests { assert!(app.unsaved_flag.load(Ordering::Relaxed)); } + /// Text typed into a new text box is lost on close unless the save prompt appears; an empty + /// box has nothing to lose. + #[test] + fn text_typed_into_a_new_text_box_counts_as_unsaved_work() { + use crate::content_ui::{TextDraft, default_style}; + let mut app = app(); + let draft = |text: &str| TextDraft { page: 0, index: None, rect: [0.0; 4], text: text.into(), style: default_style(), focus: true }; + assert!(!app.has_unsaved_work(0)); + app.views[0].content.draft = Some(draft(" \n")); + assert!(!app.has_unsaved_work(0)); + app.views[0].content.draft = Some(draft("Typed")); + assert!(!app.session.get(app.views[0].id).unwrap().dirty); + assert!(app.has_unsaved_work(0)); + } + + /// Close ▸ Save, and Quit ▸ Save, write the text typed into a new text box before the tab + /// closes. + #[test] + fn saving_on_close_keeps_text_typed_into_a_new_text_box() { + use crate::content_ui::{TextDraft, default_style}; + use pdfcraft_engine::AddedContent; + for quit in [false, true] { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-new-text-{}-{quit}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("source.pdf").to_string_lossy().into_owned(); + let bytes = include_bytes!("../tests/data/form.pdf").to_vec(); + std::fs::write(&path, &bytes).unwrap(); + let mut app = PdfCraftApp::new(); + app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap(); + let rect = [72.0, 72.0, 272.0, 92.0]; + app.views[0].content.draft = Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true }); + let ctx = egui::Context::default(); + if quit { + assert!(!app.request_quit(&ctx)); + } else { + app.request_close_tab(0); + } + assert!(app.close_request.is_some()); + app.resolve_close(&ctx, Some(true)); + assert!(app.views.is_empty(), "the tab closes once saved"); + + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("source.pdf", Some(path.clone()), std::fs::read(&path).unwrap()).unwrap(); + let doc = reopened.session.get(reopened.views[0].id).unwrap(); + assert!(doc.added().iter().any(|a| matches!(&a.content, AddedContent::Text(t) if t.text == "Typed")), "quit: {quit}"); + let _ = std::fs::remove_dir_all(&dir); + } + } + #[test] fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() { for queued in [false, true] { From e0c319d2730aba8d9d28aee1af5b746b1ebc86cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:18:59 +0000 Subject: [PATCH 33/43] Integration: a comment listed through a reference chain keeps its replies after an edit (#4061) --- crates/annot/src/lib.rs | 21 +++++++++++++++++---- crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index bd0d85cf23..6c9fc5dce8 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2328,6 +2328,18 @@ pub fn is_comment_subtype(subtype: &str) -> bool { !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") } +/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` +/// follows it, within a few steps (a cycle stops there). +fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..8 { + match &*doc.get(r) { + Object::Ref(next) => r = *next, + _ => break, + } + } + r +} + /// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); @@ -2338,13 +2350,13 @@ pub fn summaries(doc: &Document) -> Vec { for (page, list) in lists.iter().enumerate() { for (index, entry) in list.iter().enumerate() { if let Some(r) = entry.as_ref() { - slots.entry(r).or_insert((page, index)); + slots.entry(final_ref(doc, r)).or_insert((page, index)); } } } for (page, list) in lists.iter().enumerate() { for (index, entry) in list.iter().enumerate() { - let obj = doc.resolve(entry); + let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r))); let Some(d) = obj.as_dict() else { continue }; let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; if !is_comment_subtype(&subtype) { @@ -2378,8 +2390,9 @@ pub fn summaries(doc: &Document) -> Vec { "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), _ => Vec::new(), }; - let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); - let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&r).copied()); + let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); + let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); out.push(Summary { page, index, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9be485ba97..c87b7cbc7b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3839,6 +3839,37 @@ trailer << /Root 1 0 R >> check(&s, 2); } +/// A comment listed through a reference chain (`/Annots [5 0 R]`, `5 0 obj 6 0 R`) keeps its +/// replies after an edit, whether their `/IRT` names the comment or the chain, as on open. +#[test] +fn replies_thread_through_a_reference_chain_after_an_edit() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 7 0 R 8 0 R 9 0 R] >> endobj +5 0 obj 6 0 R endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (one) /Contents (CHAINED) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 130] /IRT 6 0 R /Contents (TO THE COMMENT) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 110] /IRT 5 0 R /Contents (THROUGH THE CHAIN) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (two) /Contents (PLAIN) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("chain.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens"); + let check = |s: &Session| { + let all = &s.get(id).unwrap().info.annotations; + let comment = all.iter().find(|a| a.contents.as_deref() == Some("CHAINED")).expect("listed"); + assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false), "{all:?}"); + for reply in ["TO THE COMMENT", "THROUGH THE CHAIN"] { + let a = all.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); + assert_eq!((a.parent, a.in_reply_to.as_deref()), (Some((0, 0)), Some("one")), "{reply}: {all:?}"); + } + }; + check(&s); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 3, text: "LATER".into(), author: "Delta".into() }).unwrap(); + check(&s); +} + /// #820: the generated summary includes replies to replies, indented one level deeper. #[test] fn comment_summary_includes_replies_to_replies() { From bb066495278c91ab98731c5bee3cf24ee910123c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:22:36 +0000 Subject: [PATCH 34/43] Integration: two comments whose /IRTs point at each other stay comments of their own (#4061) --- crates/ui-egui/tests/it/comments.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 538ab34724..9553aa28e3 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1351,12 +1351,12 @@ trailer << /Root 1 0 R >> // Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply, // a Replace Text strikeout with its grouped caret, and a hidden status whose comment no page // lists. Page 2: an `/IRT` to the annotation itself, to an annotation no page lists (object 11), -// to a pop-up, to a link and to a form field. +// to a pop-up, to a link and to a form field, and two annotations whose `/IRT`s point at each other. const THREAD_EDGES: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 19 0 R] >> endobj -4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 20 0 R 21 0 R] >> endobj 5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj 6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj 7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 6 0 R /T (Gamma) /Contents (Reply to the reply) >> endobj @@ -1372,6 +1372,8 @@ const THREAD_EDGES: &[u8] = b"%PDF-1.7 17 0 obj << /Type /Annot /Subtype /Text /Rect [20 50 40 70] /IRT 15 0 R /T (Delta) /Contents (Points at a link) >> endobj 18 0 obj << /Type /Annot /Subtype /Text /Rect [20 20 40 40] /IRT 16 0 R /T (Delta) /Contents (Points at a form field) >> endobj 19 0 obj << /Type /Annot /Subtype /Text /Rect [240 20 260 40] /F 30 /IRT 11 0 R /T (Delta) /StateModel /Review /State /Accepted /Contents (Stray status) >> endobj +20 0 obj << /Type /Annot /Subtype /Text /Rect [60 20 80 40] /IRT 21 0 R /T (Delta) /Contents (Loop one) >> endobj +21 0 obj << /Type /Annot /Subtype /Text /Rect [90 20 110 40] /IRT 20 0 R /T (Delta) /Contents (Loop two) >> endobj trailer << /Root 1 0 R >> %%EOF"; @@ -1384,7 +1386,9 @@ fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread( // An `/IRT` that reaches no listed comment leaves its annotation a comment of its own, // rather than a reply shown nowhere. // Links, form widgets and pop-ups are never parents: neither reader lists them as comments. - for orphan in ["Points at itself", "Points at an unlisted note", "Points at a pop-up", "Points at a link", "Points at a form field"] { + for orphan in + ["Points at itself", "Points at an unlisted note", "Points at a pop-up", "Points at a link", "Points at a form field", "Loop one", "Loop two"] + { assert!(h.get_by_label(orphan).rect().top() > h.get_by_label("Page 2").rect().top(), "{orphan}"); } // A reply to a reply joins the thread of the comment it starts from, also when that comment @@ -1392,9 +1396,9 @@ fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread( let (parent, direct, deeper) = (h.get_by_label("Unnamed parent"), h.get_by_label("Direct reply"), h.get_by_label("Reply to the reply")); assert!(parent.rect().top() < direct.rect().top() && direct.rect().top() < deeper.rect().top()); assert!(deeper.rect().top() < h.get_by_label("Struck words").rect().top(), "under its own thread"); - // Comments: the unnamed parent, the strikeout and the five on page 2. Replies: the two + // Comments: the unnamed parent, the strikeout and the seven on page 2. Replies: the two // replies, not the Replace Text caret grouped with its strikeout. - h.get_by_label("7"); + h.get_by_label("9"); h.get_by_label("2 replies"); // A status whose comment no page lists is not a comment: no card, and nothing to pick on the // page where it sits. From 80d8c60ac2822a34d61fd01c2027a0ea8b69926c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:23:47 +0000 Subject: [PATCH 35/43] Integration: no link or comment tip beside the open palette (#2685) --- crates/ui-egui/src/canvas.rs | 4 +++- crates/ui-egui/tests/it/pointer.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5cb2002f33..c7015c4651 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3884,8 +3884,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } - // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. + // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips + // draw above the palette, so none shows while it is open, even beside it. if !matches!(tool, QuickTool::Fill(_)) + && unobstructed && let Some(p) = pointer { for l in info.links.iter().filter(|l| l.page == i) { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 282f53dc02..f16c53f578 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -317,6 +317,28 @@ fn stamp_cursor_stays_off_the_command_palette() { assert_eq!(cursor_at(&mut h, beside), egui::CursorIcon::Crosshair); } +/// A link beside the open palette shows no tip: tips draw above the palette. +#[test] +fn link_tip_stays_off_while_the_palette_is_open() { + use egui_kittest::kittest::Queryable; + let mut h = open(link_pdf(), "300", false); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown"); + let beside = [60.0, 100.0, 150.0, 200.0, 240.0] + .into_iter() + .flat_map(|x| [305.0, 325.0, 345.0].map(|y| at(&h, x, y))) + .find(|p| page(&h).contains(*p) && !palette.expand(40.0).contains(*p)) + .unwrap_or_else(|| panic!("no point of the link beside the palette {palette:?}, page {:?}", page(&h))); + h.hover_at(beside); + h.run_steps(3); + assert!(h.query_by_label("https://example.org/").is_none(), "no tip beside the palette"); + h.state_mut().palette_open = false; + h.hover_at(beside); + h.run_steps(3); + h.get_by_label("https://example.org/"); +} + fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { h.hover_at(egui::pos2(800.0, 500.0)); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move }); From 756660f6d235488cd0005b8eeddfccf19774b856 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:55:10 +0000 Subject: [PATCH 36/43] Integration: the arrow keys leave a reply to an unnamed comment where it is (#4061) --- crates/ui-egui/src/comments.rs | 3 +-- crates/ui-egui/tests/it/comments.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 00435af73d..928d5cbf6b 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1881,8 +1881,7 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo return; } let Some((page, index)) = cv.selected else { return }; - let movable = - info.annotations.iter().any(|a| a.page == page && a.index == index && a.in_reply_to.is_none() && !is_markup(&a.subtype) && !a.locked); + let movable = info.annotations.iter().any(|a| a.page == page && a.index == index && !a.is_reply() && !is_markup(&a.subtype) && !a.locked); let (Some(xf), Some(p)) = (view.page_xform(page), info.pages.get(page)) else { return }; if !movable { return; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 9553aa28e3..d516330dd7 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1407,6 +1407,22 @@ fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread( assert_eq!(h.state().views[0].comments.selected, None); } +/// The arrow keys move a comment, not a reply: also not a reply to a comment with no `/NM`. +#[test] +fn arrow_keys_leave_a_reply_to_an_unnamed_comment_where_it_is() { + let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("quick", "select").unwrap()); + let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().rect; + let (reply, parent) = (rect(&h, "Direct reply"), rect(&h, "Unnamed parent")); + h.state_mut().views[0].comments.selected = Some((0, 1)); + h.key_press(egui::Key::ArrowRight); + h.run_steps(3); + assert_eq!(rect(&h, "Direct reply"), reply, "the reply stays"); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.key_press(egui::Key::ArrowRight); + h.run_steps(3); + assert_ne!(rect(&h, "Unnamed parent"), parent, "its comment moves"); +} + // Contributor-original PDF: a comment, a reply, and a checkmark and a status set on the reply. const CHECKED_REPLY: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj From c276f92b549c6625bb4329f7029f631fd82b52b7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:55:17 +0000 Subject: [PATCH 37/43] Integration: the roadmap entry for comment threads says what the count counts (#4061) --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6037b08a85..f3515c2eb1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -225,7 +225,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. -- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. From 0140f1047c529b00530b9470929ec7ee70f43037 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:56:44 +0000 Subject: [PATCH 38/43] Integration: the page input guard also catches egui's per-button pointer reads (#2685) --- crates/ui-egui/src/page_pointer.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index baf0e29613..2fa5871164 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -275,7 +275,7 @@ mod tests { ]; /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. - const RAW_READS: [&str; 15] = [ + const RAW_READS: [&str; 21] = [ "hover_pos()", "interact_pos()", "latest_pos()", @@ -291,6 +291,13 @@ mod tests { "any_pressed()", "any_released()", "any_click()", + // These take the button, so they match up to the parenthesis. + "button_down(", + "button_pressed(", + "button_released(", + "button_clicked(", + "button_double_clicked(", + "button_triple_clicked(", ]; /// Functions in the page modules that are not page input and read egui's pointer state From f4f91d0d5cfc1e54b1b755da0869974e8c52d65d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 21:03:25 -0400 Subject: [PATCH 39/43] fix(xfa): require full save when stripping XFA packets (#4568) --- crates/xfa/src/pdf.rs | 1 + crates/xfa/src/tests.rs | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e930c8f46..fd80949e8f 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -1203,6 +1203,7 @@ pub fn strip(doc: &mut Document) -> Result { for r in streams { doc.free(r); } + doc.require_full_save(); Ok(out) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 3175882f89..f0e6816c2f 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3350,6 +3350,7 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { ur.set(b"UR3".to_vec(), Object::Dict(pdfcraft_cos::Dict::new())); doc.update_dict(root, |d| d.set(b"Perms".to_vec(), Object::Dict(ur))).unwrap(); let r = strip(&mut doc).unwrap(); + assert!(doc.full_save_required()); assert_eq!(r.packets, vec!["template".to_string(), "datasets".to_string()]); assert_eq!(r.unbound_data, Some(0)); assert_eq!((r.script_buttons, r.image_fields), (0, 1), "{r:?}"); @@ -3435,6 +3436,16 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); } +#[test] +fn strip_marks_document_as_requiring_full_save() { + use crate::pdf::strip; + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); + render_into(&mut doc).unwrap(); + assert!(!doc.full_save_required()); + strip(&mut doc).unwrap(); + assert!(doc.full_save_required(), "stripping XFA packets must require a full save to prevent data exposure in incremental saves"); +} + #[test] fn som_indices_count_earlier_same_named_siblings() { let draw = |name: Option<&str>| { From 4c69b8cf88ad73aac136806e17c03b271b06f6ae Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 21:07:09 -0400 Subject: [PATCH 40/43] fix(forms): align has_password_flag traversal depth with field enumeration (#4570) * fix(forms): align has_password_flag traversal depth with field enumeration * fix(forms): extend field_chain depth to 64 and fix test formatting --- crates/forms/src/lib.rs | 8 +++--- crates/forms/src/tests.rs | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 75c3dbee00..fc10e30ebe 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1315,7 +1315,7 @@ fn password_fields(doc: &Document) -> Vec { /// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save /// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like /// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be -/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep. +/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. fn has_password_flag(doc: &Document) -> bool { let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); @@ -1323,7 +1323,7 @@ fn has_password_flag(doc: &Document) -> bool { let mut seen = std::collections::HashSet::new(); while let Some((o, depth)) = stack.pop() { let Object::Ref(r) = o else { continue }; - if depth > 32 || !seen.insert(r) { + if depth > 64 || !seen.insert(r) { continue; } if sets_it(r) { @@ -1363,11 +1363,11 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) Ok(()) } -/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats. +/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. fn field_chain(doc: &Document, r: ObjRef) -> Vec { let mut chain = vec![r]; let mut cur = r; - while chain.len() < 32 { + while chain.len() < 64 { let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; if chain.contains(&parent) { break; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 54bc137abd..09c6e39b7a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4088,6 +4088,65 @@ fn a_password_field_missing_from_the_field_list_is_still_cleared() { } } +/// A password field nested beyond depth 32 (e.g. depth 35) is recognized by `has_password_flag` +/// and `has_password_values`, matching the field tree walk limit of 64. +#[test] +fn a_password_field_nested_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + objs.push(format!("<< /T (node{d}) /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /Ff 8192 /V (secret35) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + +/// A password field whose widget is reachable through a page's Annots and whose inherited Password +/// flag is deeper than 32 levels is recognized by `has_password_flag`. +#[test] +fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{leaf_obj_num} 0 R] >>"), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + let ff_entry = if d == 0 { " /Ff 8192" } else { "" }; + objs.push(format!("<< /T (node{d}){ff_entry} /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /V (secret_chain) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + /// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut /// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. #[test] From aa9a9105fc489ccd09333793e7b9d995a88ef6f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 21:07:16 -0400 Subject: [PATCH 41/43] fix(redact): remove AcroForm on Hidden::FormFields even when field count is zero (#4571) * fix(redact): remove AcroForm on Hidden::FormFields even when field count is zero * fix(redact): report AcroForm presence in scan() and format --- crates/redact/src/sanitize.rs | 19 ++++++++++++++++--- crates/redact/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 8625f99c25..202ece31cb 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -474,7 +474,16 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + owned(Hidden::Attachments) } Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), - Hidden::FormFields => pdfcraft_forms::fields(doc).len(), + Hidden::FormFields => { + let n = pdfcraft_forms::fields(doc).len(); + if n > 0 { + n + } else if cat.contains(b"AcroForm") { + 1 + } else { + 0 + } + } Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); @@ -510,7 +519,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0; + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -631,9 +641,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { + cat.remove(b"AcroForm"); + } _ => {} } - let n = count(h); + let n = if h == Hidden::FormFields && fields { count(h).max(1) } else { count(h) }; if n > 0 { done.push((h, n)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index cf438d4130..fa39853a7f 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2708,6 +2708,27 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } +/// F-021: removing form fields on a document with an AcroForm dictionary (even one with no +/// enumerable terminal fields) removes the AcroForm dictionary, processes widgets, and requires full save. +#[test] +fn removing_form_fields_removes_acroform_even_without_terminal_fields() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Annot /Subtype /Widget /Rect [10 200 150 220] >>".to_vec()]); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7]))).unwrap(); + let counts = scan(&doc); + assert_eq!(counts.iter().find(|c| c.0 == Hidden::FormFields).map(|c| c.1), Some(1)); + assert!(doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(!doc.full_save_required()); + + let done = remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_eq!(done, [(Hidden::FormFields, 1)]); + assert!(!doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); + assert!(doc.full_save_required()); +} + /// Removing hidden text cleans the structure tree as redaction does: the elements describing the /// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. #[test] From 24edde656349c9145a501d450fab3cca62490745 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 25 Oct 2026 09:08:21 +0800 Subject: [PATCH 42/43] forms: clear typed XFA password field values from datasets on save (#3944) (#4506) --- crates/engine/src/lib.rs | 49 +++++--- crates/engine/src/optimizer.rs | 3 +- crates/engine/src/tests.rs | 76 ++++++++++++ crates/forms/src/lib.rs | 19 ++- crates/xfa/src/data.rs | 215 ++++++++++++++++++++++++++++++++- crates/xfa/src/lib.rs | 6 +- crates/xfa/src/script.rs | 38 +++++- crates/xfa/src/tests.rs | 66 ++++++++++ 8 files changed, 442 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f195b10cc6..f11671bf85 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3070,16 +3070,24 @@ fn renderer_password(bytes: &[u8]) -> String { } } -/// The document as it may be written to a file (#3926): a copy without the values of text fields -/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 -/// (a note to the Password flag): "it is imperative that PDF processors never store the value of -/// the text field in the PDF file if this flag is set". The open document keeps them in memory. +/// The document as it may be written to a file (#3926, #3944): a copy without the values of text +/// fields with the Password flag (or XFA `` fields in the datasets), or `cos` +/// itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 (a note to the Password flag): +/// "it is imperative that PDF processors never store the value of the text field in the PDF file +/// if this flag is set". The open document keeps them in memory. fn writable(cos: &pdfcraft_cos::Document) -> Result, EditError> { - if !pdfcraft_forms::has_password_values(cos) { + let has_form_pw = pdfcraft_forms::has_password_values(cos); + let has_xfa_pw = pdfcraft_xfa::has_password_values(cos); + if !has_form_pw && !has_xfa_pw { return Ok(std::borrow::Cow::Borrowed(cos)); } let mut copy = cos.clone(); - pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + if has_form_pw { + pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + } + if has_xfa_pw { + pdfcraft_xfa::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + } Ok(std::borrow::Cow::Owned(copy)) } @@ -4516,11 +4524,15 @@ impl Session { // An encrypted file needs the password it is protected with now (the owner // password when known, so saving never downgrades this session's rights). let keys = editor.keys.clone(); - // The saved file carries no Password-field values (#3943); the open document keeps them. + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. let typed = pdfcraft_forms::password_values(&editor.cos); editor.cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } editor.saved = bytes; editor.saved_keys = keys; editor.unsaved_file = false; @@ -5125,17 +5137,18 @@ impl Session { let mut out = Vec::new(); for d in &mut self.docs { if d.dirty && d.generation != d.snapshot_generation { - // The working bytes hold typed Password-field values (#3926): a recovery file is - // written from a copy without them, and none is written if that copy fails. - let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) { - Some(e) => match writable(&e.cos) - .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) - { - Ok(b) => Arc::new(b), - Err(_) => continue, - }, - None => d.bytes.clone(), - }; + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; d.snapshot_generation = d.generation; out.push(RecoverySnapshot { doc: d.id, diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index a3becf5147..42af737bab 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -87,8 +87,9 @@ impl OptimizeJob { fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { let OptimizeJob { mut cos, settings, discard, opts } = self; - // Like every file written, without the values of Password-flag fields (#3926). + // Like every file written, without the values of Password-flag fields (#3926, #3944). pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; + pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c87b7cbc7b..ba6f2c1c7b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2284,6 +2284,82 @@ fn a_signed_file_carries_no_password_field_value() { assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length"); } +/// #3944: a value typed into an XFA `` field stays in the open document (both the +/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`), +/// but is written to no file — neither on the widget nor in the XFA datasets stream. +#[test] +fn xfa_password_field_values_are_not_saved_in_datasets_or_widgets() { + let xfa_pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template("")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("xfa.pdf", None, Arc::new(xfa_pdf), None).unwrap(); + let pin_name = s.get(id).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone(); + + // Edit the password twice so an earlier edit's datasets stream could not linger in the overlay. + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret1".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + + let field = s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold the live datasets"); + assert!(!appears_in_pdf(&s.get(id).unwrap().bytes, "secret1"), "the first edit's datasets stream was replaced in place"); + + let saved = s.save_bytes(id).unwrap(); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&saved, leak), "Save leaked {leak}"); + assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), leak), "save_full_bytes leaked {leak}"); + assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, leak), "reduced_bytes leaked {leak}"); + } + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&snaps[0].bytes, leak), "autosave snapshot leaked {leak}"); + } + + // Reopening the saved file in a new session leaves the password field empty (it does not + // repopulate from the XFA datasets on open). + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert!( + t.get(reopened).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value.iter().all(|v| v.is_empty()), + "reopened XFA form must have an empty password field" + ); + + // Recording the save keeps the typed password in the open document (and its live datasets), + // while a subsequent save still omits it. + s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value, ["hunter2"]); + assert!(!s.get(id).unwrap().dirty); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), leak), "save after mark_saved leaked {leak}"); + } + + // Undo past `mark_saved` and edit again: the datasets stream in the restored overlay is + // overwritten in place rather than left behind. + s.undo(id).unwrap(); + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret3".into()) }).unwrap(); + let saved_after_undo = s.save_bytes(id).unwrap(); + for leak in ["hunter2", "secret1", "secret3", "*******"] { + assert!(!appears_in_pdf(&saved_after_undo, leak), "save after undo leaked {leak}"); + } + + // A relayout (`rerender`) after typing a password frees the replaced generated widget and its + // masked appearance stream so neither remains in the overlay when saving. + let relayout_xml = pdfcraft_xfa::fixtures::fields_template("").replace( + r#""#, + r#""#, + ); + let id2 = s.open("relayout.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&relayout_xml)), None).unwrap(); + let pin2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone(); + let grow2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("grow")).unwrap().name.clone(); + s.apply(id2, Edit::SetFieldValue { name: pin2, value: FieldValue::Text("s3cr3t!".into()) }).unwrap(); + s.apply(id2, Edit::SetFieldValue { name: grow2, value: FieldValue::Text("line1\nline2\nline3\nline4\nline5".into()) }).unwrap(); + let saved_relayout = s.save_bytes(id2).unwrap(); + for leak in ["s3cr3t!", "*******"] { + assert!(!appears_in_pdf(&saved_relayout, leak), "save after relayout leaked {leak}"); + } +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index fc10e30ebe..2354d69f6b 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1253,6 +1253,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), _ => None, }; + let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); let mut undrawn = false; for w in &f.widgets { let (stream, missing) = match &shown { @@ -1260,7 +1261,23 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc None => appearance::field_appearance_drawn(doc, f, w, values, true), }; undrawn |= missing.is_some(); - let ap = doc.add(Object::Stream(stream)); + let existing_ap = in_place + .then(|| { + doc.get(w.obj) + .as_dict() + .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) + .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| n.as_ref()) + .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) + }) + .flatten(); + let ap = match existing_ap { + Some(r) => { + doc.set(r, Object::Stream(stream)); + r + } + None => doc.add(Object::Stream(stream)), + }; let mut fresh = Dict::new(); fresh.set(b"N".to_vec(), Object::Ref(ap)); let apd = merged_appearance(doc, w.obj, &fresh); diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 95f59a44b9..eac2f20bf4 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::XfaError; -use crate::model::{Field, Node, SomIndices, Template}; +use crate::model::{Field, Node, SomIndices, Template, Ui}; use crate::packets::{Encoding, encode}; const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; @@ -1630,11 +1630,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) } /// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream -/// `reuse`, else as a new object. +/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); - match (old.and_then(Object::as_ref), reuse) { - (Some(r), Some(keep)) if r == keep => { + match old.and_then(Object::as_ref) { + Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { doc.set(r, stream); r } @@ -1794,3 +1794,210 @@ pub fn is_on(text: &str, on: &str) -> bool { } !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() } + +/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or +/// the single `/XFA` stream), when `doc` has one. +fn datasets_stream_ref(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; + let xfa = acro.as_dict()?.get(b"XFA")?; + match &*doc.resolve(xfa) { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; + items.get(at + 1)?.as_ref() + } + Object::Stream(_) => xfa.as_ref(), + _ => None, + } +} + +/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, +/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm +/// dictionary. +fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { + const PASSWORD_FLAG: i64 = 1 << 13; + let Some(root) = doc.root() else { return Vec::new() }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; + let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { + return Vec::new(); + }; + struct AcroWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + out: Vec<(String, String)>, + } + impl AcroWalk<'_> { + fn walk( + &mut self, + items: &[Object], + parent_name: &str, + inherited_ft: Option<&[u8]>, + inherited_ff: i64, + inherited_dv: Option<&str>, + depth: usize, + ) { + if depth > 32 { + return; + } + for item in items { + let Some(r) = item.as_ref() else { continue }; + if !self.seen.insert(r) { + continue; + } + let obj = self.doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let ft = d.name(b"FT").or(inherited_ft); + let ff = d.int(b"Ff").unwrap_or(inherited_ff); + let dv_own = text_of(self.doc, d, b"DV"); + let dv = dv_own.as_deref().or(inherited_dv); + let t = text_of(self.doc, d, b"T"); + let full_name = match (&t, parent_name.is_empty()) { + (Some(seg), true) => seg.clone(), + (Some(seg), false) => format!("{parent_name}.{seg}"), + (None, _) => parent_name.to_string(), + }; + let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); + if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { + let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); + if !som.is_empty() { + self.out.push((som, dv.unwrap_or_default().to_string())); + } + } + if !kids.is_empty() { + self.walk(&kids, &full_name, ft, ff, dv, depth + 1); + } + } + } + } + let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; + w.walk(&top, "", None, 0, None, 0); + w.out +} + +/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their +/// defaults (or empty when they have no default). +fn password_dataset_wants(doc: &Document) -> Vec { + if datasets_stream_ref(doc).is_none() { + return Vec::new(); + } + let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&packet) else { return Vec::new() }; + with_template(doc, None, |tpl| { + let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + if let Some(t) = tpl { + struct Walk<'a> { + tpl: &'a Template, + data: &'a DataNode, + nodes: usize, + seen: &'a mut std::collections::HashSet, + out: &'a mut Vec<(String, DataPath, String)>, + } + impl Walk<'_> { + fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { + match name { + Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), + Some(n) => format!("{n}[{index}]"), + None => parent.to_string(), + } + } + fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { + if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { + return; + } + let sibs = SomIndices::of(children); + for (i, n) in children.iter().enumerate() { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + let sib = sibs.at(i); + match n { + Node::Subform(sf) => { + let from_data = match &sf.common.name { + Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); + for k in 0..instances { + if k > 0 { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + } + let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); + self.walk(&sf.children, &som, depth + 1); + } + } + Node::Area(a) => { + let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); + self.walk(&a.children, &som, depth + 1); + } + Node::Field(f) if f.ui == Ui::PasswordEdit => { + let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); + let path = data_path(self.tpl, &som); + if !path.is_empty() && self.seen.insert(path.clone()) { + let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); + self.out.push((som, path, default)); + } + } + _ => {} + } + } + } + } + let root = Node::Subform(Box::new(t.root.clone())); + let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; + w.walk(std::slice::from_ref(&root), "", 0); + if let Some(ps) = &t.root.page_set { + for (i, area) in ps.areas.iter().enumerate() { + let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); + w.walk(&area.items, &som, 1); + } + } + } + for (som, default) in acro_password_fields(doc) { + let path = path_of(tpl, &som); + if !path.is_empty() && seen.insert(path.clone()) { + candidates.push((som, path, default)); + } + } + candidates + .into_iter() + .filter_map(|(som, path, default)| { + let cur = data.value_at(&path)?; + (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) + }) + .collect() + }) +} + +/// Whether any XFA password field (`` or an AcroForm text field with the Password +/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). +pub fn has_password_values(doc: &Document) -> bool { + !password_dataset_wants(doc).is_empty() +} + +/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default +/// where the field has one, else emptying the element), replacing the datasets stream in place +/// (#3944). Returns how many password fields were cleared in the datasets. +pub fn clear_password_values(doc: &mut Document) -> Result { + let wants = password_dataset_wants(doc); + if wants.is_empty() { + return Ok(0); + } + let count = wants.len(); + let reuse = datasets_stream_ref(doc); + let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; + if !report.written || !password_dataset_wants(doc).is_empty() { + let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); + return Err(XfaError::Malformed(reason.to_string())); + } + Ok(count) +} diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 7fe372d53a..41a250a546 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -92,9 +92,9 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize { pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use data::{ - DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets, - pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, - write_datasets_reusing, write_datasets_with, + DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path, + has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, + write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index d330c2a647..80fb1db33c 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -586,12 +586,44 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result if form.pages.is_empty() { return Err(XfaError::Malformed("the template laid out to no pages".into())); } - // The AcroForm keeps only the fields PdfCraft did not generate. + // The AcroForm keeps only the fields PdfCraft did not generate. Any generated fields edited in + // this overlay (along with their appearance streams and widget kids) are freed so a replaced + // password widget's value or masked appearance cannot stay in the overlay. let Some(root) = doc.root() else { return Err(XfaError::NotXfa) }; if let Some((acro_ref, mut acro)) = acroform(doc) { let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let kept: Vec = - fields.into_iter().filter(|f| f.as_ref().is_none_or(|r| doc.get(r).as_dict().is_none_or(|d| !d.contains(crate::pdf::SOM_KEY)))).collect(); + let mut kept = Vec::new(); + let free_ap = |doc: &mut Document, d: &Dict| { + if let Some(ap) = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) + && let Some(n) = ap.get(b"N").and_then(Object::as_ref) + && doc.is_edited(n.num) + { + doc.free(n); + } + }; + for f in fields { + if let Some(r) = f.as_ref() + && let Some(d) = doc.get(r).as_dict().cloned() + && d.contains(crate::pdf::SOM_KEY) + { + free_ap(doc, &d); + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + for k in kids.iter().filter_map(Object::as_ref) { + if let Some(kd) = doc.get(k).as_dict().cloned() { + free_ap(doc, &kd); + } + if doc.is_edited(k.num) { + doc.free(k); + } + } + } + if doc.is_edited(r.num) { + doc.free(r); + } + } else { + kept.push(f); + } + } acro.set(b"Fields".to_vec(), Object::Array(kept)); match acro_ref { Some(r) => doc.set(r, Object::Dict(acro)), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index f0e6816c2f..cb106de841 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3791,3 +3791,69 @@ fn faces_measure_text_at_their_standard_14_widths() { let condensed = text::Face { h_scale: 0.8, ..face(text::Family::Times, true, false) }; assert!((condensed.width(sample) - 0.8 * Std14::TimesBold.text_width(sample, 10.0)).abs() < 1e-9); } + +/// #3944: clearing XFA password values removes values typed into `` fields from the +/// datasets packet (including across repeating subforms), restores a field's template default +/// `…` when it has one, and replaces the datasets stream in place so +/// the typed password is not left in an unreferenced stream in the overlay. +#[test] +fn clear_password_values_removes_typed_passwords_from_datasets_in_place() { + let xdp = r#" + + +
Adahunter29999firstsecond
+
+
"#; + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + render_into(&mut doc).unwrap(); + assert!(has_password_values(&doc)); + let cleared = clear_password_values(&mut doc).unwrap(); + assert_eq!(cleared, 4); + assert!(!has_password_values(&doc), "only default and non-password values remain"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 0); + + let packets = read_packets(&doc).unwrap().unwrap(); + assert!(packets.xdp.contains("Ada"), "non-password fields stay: {}", packets.xdp); + assert!(packets.xdp.contains("0000"), "template default restored: {}", packets.xdp); + for secret in ["hunter2", "9999", "first", "second"] { + assert!(!packets.xdp.contains(secret), "{secret} still in datasets: {}", packets.xdp); + } + let written = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(written)).unwrap(); + for num in reopened.object_numbers() { + if let Object::Stream(st) = &*reopened.get(pdfcraft_cos::ObjRef::new(num, 0)) { + let decoded = st.decoded().unwrap_or_default(); + let text = String::from_utf8_lossy(&decoded); + for secret in ["hunter2", "9999", "first", "second"] { + assert!(!text.contains(secret), "object {num} still holds {secret}: {text}"); + } + } + } +} + +/// #3944: a static XFA form whose AcroForm field sets the Password flag (`/Ff 8192`) also has its +/// typed value cleared from the datasets packet. +#[test] +fn clear_password_values_clears_static_xfa_password_fields() { + let pdf = String::from_utf8(static_shell("hunter2")) + .unwrap() + .replace("/T (name[0])", "/T (name[0]) /Ff 8192"); + let mut doc = Document::open(Arc::new(pdf.into_bytes())).unwrap(); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); + let packets = read_packets(&doc).unwrap().unwrap(); + assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); +} From 418ac6a2af49b7ac2fed44ca2cae8d5d1d2cec0d Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:12:46 -0400 Subject: [PATCH 43/43] fix(ui): the page ignores the pointer while a panel is being dragged (#4581) --- crates/ui-egui/src/page_pointer.rs | 27 +++++++++++++----- crates/ui-egui/tests/it/pointer.rs | 46 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index 2fa5871164..ebb81ab5fa 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -1,8 +1,9 @@ //! The pointer as the pages see it, read once per frame from the page view's content widget. //! //! egui knows what is drawn over the pages (a dialog, a menu, the palette, the quick bar, the -//! chrome over a page scrolled under it) and keeps a press with the widget it began on until -//! release. egui's raw pointer state knows neither, and its hover position is gone on the frame +//! chrome over a page scrolled under it) and which widget holds a drag (a panel's tab carried +//! over them), and keeps a press with the widget it began on until release. egui's raw pointer +//! state knows none of that, and its hover position is gone on the frame //! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and //! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's //! pointer positions or button states; a test below keeps it that way. @@ -26,10 +27,11 @@ const MAX_EDGES: usize = 8; /// One frame's pointer, as the pages see it. #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct PagePointer { - /// Where the pointer is, while the pages are what egui has under it, or while they hold a - /// press (until the frame of its release, past a page's edge included). With a dialog open - /// only a drag the pages already have keeps it. It is egui's `interact_pos`, which still has - /// the position on the frame a touch lifts. + /// Where the pointer is, while the pages are what egui has under it and no other widget holds + /// a drag (a panel's tab carried over them), or while they hold a press (until the frame of + /// its release, past a page's edge included). With a dialog open only a drag the pages + /// already have keeps it. It is egui's `interact_pos`, which still has the position on the + /// frame a touch lifts. pub pos: Option, /// Where the press the pages hold began, also on the frame of its release when the press /// came in that same frame (egui has forgotten it by then). `None` for a press that began on @@ -59,7 +61,8 @@ impl PagePointer { // pages (egui's interact radius), so the top layer there must be the pages' own. A // dialog covers them from the frame it opens, before egui knows its layer. let layer = ui.layer_id(); - let on_top = |p: &Pos2| !modal && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); + let on_top = + |p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); let pos = at.filter(|p| held || on_top(p)); if !held { return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; @@ -78,6 +81,16 @@ impl PagePointer { } } +/// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's +/// grip): egui hovers nothing else until it stops, though it still reports the pages under the +/// pointer. A widget on the pages (a form field's editor) keeps them. +fn dragged_elsewhere(ui: &egui::Ui, resp: &egui::Response) -> bool { + let ctx = ui.ctx(); + ctx.dragged_id().is_some_and(|id| { + id != resp.id && !ctx.read_response(id).is_some_and(|r| r.layer_id == resp.layer_id && resp.interact_rect.contains_rect(r.interact_rect)) + }) +} + fn first_press(edges: &[Edge]) -> Option { edges.iter().find_map(|e| match e { Edge::Press(p) => Some(*p), diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index f16c53f578..145446cc13 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -339,6 +339,52 @@ fn link_tip_stays_off_while_the_palette_is_open() { h.get_by_label("https://example.org/"); } +/// A panel's tab carried over a link shows neither its tip nor its cursor, and the link answers +/// again once the tab is let go. +#[test] +fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let mut h = open(link_pdf(), "150", false); + h.state_mut().set_option("panel", "comments").unwrap(); + for _ in 0..4 { + h.run_steps(2); + } + let on_link = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + // Away from the link, then the Inspector's tab is picked up and carried over it. + h.hover_at(at(&h, 150.0, 100.0)); + h.run_steps(4); + let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")).rect().center(); + h.hover_at(tab); + h.run_steps(1); + h.drag_at(tab); + h.run_steps(1); + for i in 1..=8 { + h.hover_at(tab + (on_link - tab) * (i as f32 / 8.0)); + h.run_steps(1); + } + for _ in 0..4 { + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "the tab is still being dragged"); + assert!(!tip_shown(&h), "no link tip under a dragged panel tab"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + } + // Back to the tab and let go there, which leaves the layout as it was. + for i in 1..=8 { + h.hover_at(on_link + (tab - on_link) * (i as f32 / 8.0)); + h.run_steps(1); + } + h.drop_at(tab); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_none()); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "the link answers again"); + h.run_steps(2); + assert!(tip_shown(&h), "and shows its tip"); +} + fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { h.hover_at(egui::pos2(800.0, 500.0)); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move });