From f59320b1a0e70ef0f34eeed42a6f56b813c215c0 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:36:56 +0300 Subject: [PATCH 1/7] fix(web): host mode needs a parent page on the same origin With ?host=parent, saves are posted to the parent with the app's own origin as the target. A framing page on another origin never gets them: the browser drops each message without an error, post() returns Ok, and the document is marked saved and the leave-page warning cleared (#2167). Bridge::new now returns None unless the parent's origin is the app's own (reading another origin's location throws, which is None too). The app then runs as if unframed and saves download, and a console warning says why. Same-origin embedding is unchanged. --- apps/pdfcraft-web/src/main.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index a46a4674b..73d14f4a6 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -70,6 +70,10 @@ fn main() { app.host_save = Some(bridge.save_fn()); app.host_dirty = Some(bridge.dirty_fn()); bridge.post_ready(); + } else if query_param("host").as_deref() == Some("parent") { + eframe::web_sys::console::warn_1( + &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(), + ); } Ok(Box::new(app)) }), @@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result, String> { /// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. /// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. /// -/// Only the parent window on the app's own origin is heard or answered. +/// Only the parent window on the app's own origin is heard or answered. With a parent on +/// another origin there is no bridge: the app runs as if unframed and saves download. #[cfg(target_arch = "wasm32")] mod host { use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; @@ -143,7 +148,9 @@ mod host { } impl Bridge { - /// `None` when the page isn't framed (there is no parent to talk to). + /// `None` when the page isn't framed (there is no parent to talk to), or when the parent + /// is on another origin: messages only go to this origin, so the browser would drop + /// every save without an error while the document showed as saved (#2167). pub fn new() -> Option { let window = web_sys::window()?; let parent = window.parent().ok().flatten()?; @@ -151,6 +158,10 @@ mod host { return None; } let origin = window.location().origin().ok()?; + // Reading another origin's location throws, which is `None` too. + if parent.location().origin().ok()? != origin { + return None; + } Some(Self { window, parent, origin }) } From 67d35b70a0b55b8f7a3fd2bb135fc3dc0aaf2db9 Mon Sep 17 00:00:00 2001 From: magdale76 <16279778+magdale76@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:09:16 +0200 Subject: [PATCH 2/7] M14: no PdfCraft lion over PDF thumbnails and the preview pane With PdfCraft as the default PDF program, Windows painted the program's icon, the lion, over the lower-right corner of every PDF thumbnail and of the preview pane, covering part of the page. Thumbnail overlays come from the ProgID's TypeOverlay value. When it is absent, Windows uses the default program's icon, and an empty string means no overlay ("Thumbnail Handlers", Microsoft Learn). The installer now writes TypeOverlay = "" on PdfCraft.Document. Co-Authored-By: Claude Opus 5.5 --- packaging/windows/pdfcraft.wxs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 7fae1329f..d96f2cd78 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -111,6 +111,11 @@ Resource 1 is the app's lion; resource 2 is the PDF document (build.rs). --> + + From 294fea2fb8e16d027b3fd6ab3faae337070e4ed1 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 16 Oct 2026 01:34:32 +0800 Subject: [PATCH 3/7] fix(ui): Reveal in File Explorer selects files whose path has a space (#2579) Explorer reads /select,"". The path went in as one ordinary argument, which Rust quotes whole when it contains a space ("/select,C:\a b.pdf"), and Explorer answers that by opening Documents. A file saved to a folder or name with a space (often after Save As) was therefore never shown. reveal_command now quotes the path after the comma and the Windows runner passes the arguments as written; a path containing a quote, which Windows doesn't allow, is refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/ui-egui/src/tab_context.rs | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tab_context.rs b/crates/ui-egui/src/tab_context.rs index aa16ef710..6f5319d63 100644 --- a/crates/ui-egui/src/tab_context.rs +++ b/crates/ui-egui/src/tab_context.rs @@ -56,7 +56,19 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { } let platform = std::env::consts::OS; let run = |program: &str, args: &[std::ffi::OsString]| { - std::process::Command::new(program).args(args).output().map(|o| o.status.success()).map_err(|e| e.to_string()) + let mut command = std::process::Command::new(program); + // Windows: `reveal_command` already quotes what Explorer needs quoted, and Rust's own + // quoting of an argument with a space breaks `/select,` (#2579), so pass them as written. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + for arg in args { + command.raw_arg(arg); + } + } + #[cfg(not(windows))] + command.args(args); + command.output().map(|o| o.status.success()).map_err(|e| e.to_string()) }; if matches!(platform, "linux" | "freebsd") { // `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run), @@ -95,9 +107,16 @@ fn reveal_via( fn reveal_command(platform: &str, path: &std::path::Path) -> Result<(&'static str, Vec), String> { match platform { "windows" => { - // Explorer uses /select,, rather than a separate option value. - let mut selection = std::ffi::OsString::from("/select,"); + // Explorer reads `/select,""`: the path quoted after the comma (#2579). Quoting + // the whole argument instead ("/select,C:\a b.pdf", what an ordinary argument with a + // space becomes) opens Documents. A Windows path can't contain `"`, so the quotes + // can't be closed early; one that does is refused. + if path.as_os_str().as_encoded_bytes().contains(&b'"') { + return Err("The file name can't be shown in File Explorer".into()); + } + let mut selection = std::ffi::OsString::from("/select,\""); selection.push(path); + selection.push("\""); Ok(("explorer.exe", vec![selection])) } "macos" => Ok(("open", vec!["-R".into(), path.as_os_str().into()])), @@ -229,10 +248,13 @@ mod tests { assert_eq!(args, [std::ffi::OsString::from("-R"), path.as_os_str().into()]); let (exe, args) = reveal_command("windows", path).unwrap(); assert_eq!(exe, "explorer.exe"); - assert_eq!(args, [std::ffi::OsString::from("/select,/tmp/年度 report.pdf")]); + assert_eq!(args, [std::ffi::OsString::from("/select,\"/tmp/年度 report.pdf\"")]); + // #2579: the path is quoted after the comma, never the whole argument, which Explorer + // reads as no selection and answers by opening Documents. let special = std::path::Path::new(r"C:\Reports\年度 report, #1 & (draft).pdf"); let (_, windows) = reveal_command("windows", special).unwrap(); - assert_eq!(windows, [std::ffi::OsString::from(r"/select,C:\Reports\年度 report, #1 & (draft).pdf")]); + assert_eq!(windows, [std::ffi::OsString::from(r#"/select,"C:\Reports\年度 report, #1 & (draft).pdf""#)]); + assert!(reveal_command("windows", std::path::Path::new(r#"C:\a" b.pdf"#)).is_err(), "a quote can't end the path early"); let (exe, args) = reveal_command("linux", path).unwrap(); assert_eq!(exe, "dbus-send"); assert_eq!(args[6], "array:string:file:///tmp/%E5%B9%B4%E5%BA%A6%20report.pdf"); From c73669768065052c2b0e4ef1cba9282d04882201 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 06:13:19 +0200 Subject: [PATCH 4/7] fix(ui): Save asks where to keep an Apple Mail attachment (fixes #2751) --- crates/ui-egui/src/editing.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68..3f5e35256 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -972,13 +972,15 @@ fn is_form_edit(edit: &Edit) -> bool { /// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the /// `Microsoft.OutlookForWindows` package folder for the new Outlook. Saving there either fails, /// because Outlook can still hold the file, or appears to work while the edits stay out of the -/// email and are deleted with the cache. Matched on either separator and in any case, so it can -/// be tested on every platform. +/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads//` in +/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on +/// either separator and in any case, so it can be tested on every platform. pub(crate) fn is_mail_temp_copy(path: &str) -> bool { let p = path.replace('\\', "/").to_ascii_lowercase(); p.contains("/inetcache/content.outlook/") || p.contains("/temporary internet files/content.outlook/") || p.contains("/microsoft.outlookforwindows") + || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") } /// Write via a temporary file in the same directory and rename over the target, so a crash or @@ -1296,6 +1298,16 @@ mod tests { } } + #[test] + fn apple_mail_attachment_copies_are_recognised() { + for p in [ + "/Users/me/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/6F1C2A9E-3B4D-4E5F-8A7B-9C0D1E2F3A4B/booking.pdf", + "/Users/me/library/containers/COM.APPLE.MAIL/data/library/mail downloads/6F1C2A9E/Booking (2).pdf", + ] { + assert!(is_mail_temp_copy(p), "{p}"); + } + } + #[test] fn ordinary_paths_are_not_mail_copies() { for p in [ @@ -1303,6 +1315,8 @@ mod tests { r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf", r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf", "/home/me/Documents/Quote.pdf", + "/Users/me/Downloads/Mail Downloads/booking.pdf", + "/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf", "Quote.pdf", ] { assert!(!is_mail_temp_copy(p), "{p}"); From e447749109a0c98949515a4c35c21aae56fbca1a Mon Sep 17 00:00:00 2001 From: Ahmad Mujahid Date: Fri, 16 Oct 2026 11:22:54 +0400 Subject: [PATCH 5/7] Fix wrapped revision controls overlapping Binding (#2739) --- ROADMAP.md | 2 ++ crates/ui-egui/src/dialogs.rs | 23 ++++++------ crates/ui-egui/tests/viewer.rs | 64 ++++++++++++++++++++++++++++++++++ docs/roadmap.md | 3 +- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..ca3840ae5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M3, #2739: wrapped revision buttons stay above Binding; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) @@ -157,6 +158,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629..0e5134dd3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -354,17 +354,20 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let ends = doc.revision_ends(); if ends.len() > 1 { ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); - ui.horizontal_wrapped(|ui| { - ui.label(ends.len().to_string()); - for n in (1..ends.len()).rev().take(12) { - if ui - .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) - .on_hover_text(tl!("Open the file as it was saved then")) - .clicked() - { - open_revision = Some(n); + // Reserve the full wrapped height before the grid starts the next row. + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(ends.len().to_string()); + for n in (1..ends.len()).rev().take(12) { + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() + { + open_revision = Some(n); + } } - } + }); }); ui.end_row(); } diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 474360865..0b1207d83 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -902,6 +902,70 @@ fn an_earlier_revision_opens_from_document_properties() { ); } +#[test] +fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() { + use pdfcraft_engine::{Edit, Session}; + let mut bytes = std::sync::Arc::new(fixture(1)); + // Reopen each save so the next edit appends to the preceding saved revision. + for revision in 2..=13 { + let mut source = Session::new().with_clock(|| 1_700_000_000); + let id = source.open("revisions.pdf", None, bytes, None).unwrap(); + source.apply(id, Edit::SetInfo { key: "Title".into(), value: format!("Revision {revision}") }).unwrap(); + bytes = source.save_bytes(id).unwrap(); + } + for width in [1400.0, 720.0] { + let pdf = bytes.clone(); + let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("revisions.pdf", None, pdf.to_vec()).unwrap(); + app + }); + let s = h.state(); + let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap(); + assert_eq!(doc.revision_ends().len(), 13, "the fixture contains thirteen saved revisions"); + h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::Advanced)); + // Let the grid and the centered dialog settle before measuring or clicking. + h.run_steps(6); + let buttons: Vec<_> = (1..13).map(|n| (n, h.get_by_label(&format!("View revision {n}")).rect())).collect(); + let first_row = h.get_by_label("View revision 12").rect(); + let lower_revision = buttons + .iter() + .find(|(n, rect)| *n > 1 && rect.top() > first_row.bottom()) + .map(|(n, _)| *n) + .expect("revision buttons wrap to a lower row"); + let binding = h.get_by_label("Binding").rect(); + let left = h.get_by_label("Left Edge").rect(); + let right = h.get_by_label("Right Edge").rect(); + // Properties requests a fixed width. Measure its card in each viewport. + let card = h.ctx.data(|d| d.get_temp::(egui::Id::new("dialog").with("card"))).expect("Properties is shown"); + for (n, rect) in &buttons { + for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] { + assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}"); + } + } + for (n, rect) in &buttons { + assert!( + rect.left() >= card.left() && rect.right() <= card.right(), + "revision {n} escapes Properties horizontally at viewport width {width}: {rect:?}, measured card {card:?}" + ); + } + h.get_by_label(&format!("View revision {lower_revision}")).scroll_to_me(); + h.run_steps(3); + h.get_by_label(&format!("View revision {lower_revision}")).click(); + h.run_steps(3); + let s = h.state(); + assert!(s.dialog.is_none(), "opening a revision closes Properties"); + let view = &s.views[s.active.unwrap()]; + let doc = s.session.get(view.id).unwrap(); + assert_eq!(doc.name, format!("revisions (revision {lower_revision}).pdf")); + assert_eq!(doc.info_value("Title"), Some(format!("Revision {lower_revision}"))); + let snapshot = view.snapshot.as_ref().expect("the saved revision opens as a snapshot"); + assert_eq!(snapshot.kind, pdfcraft_ui_egui::canvas::SnapshotKind::Revision); + assert_eq!(snapshot.revision, lower_revision); + } +} + #[test] fn advanced_search_lists_results_with_context() { let mut h = harness(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f..1ceaca573 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M3, #2739: wrapped revision buttons stay above Binding; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -101,7 +102,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | From 96478f325d5bded8797efea9e6cd1dbccd78a639 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:40:55 +0800 Subject: [PATCH 6/7] fix(ui): test that the Explorer argument reaches the process as written (#2579) Review of #2625: the test checked the argument reveal_command builds but not how it is passed, so it still passed with raw_arg replaced by args. The process is now built by reveal_process, and a Windows test runs it on `cmd /c echo`, which prints its command line back: with Rust's own quoting the argument comes back wrapped in quotes with the inner ones escaped, and the test fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/ui-egui/src/tab_context.rs | 48 +++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/tab_context.rs b/crates/ui-egui/src/tab_context.rs index 6f5319d63..7cc502722 100644 --- a/crates/ui-egui/src/tab_context.rs +++ b/crates/ui-egui/src/tab_context.rs @@ -55,21 +55,8 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { return Err("The file no longer exists".into()); } let platform = std::env::consts::OS; - let run = |program: &str, args: &[std::ffi::OsString]| { - let mut command = std::process::Command::new(program); - // Windows: `reveal_command` already quotes what Explorer needs quoted, and Rust's own - // quoting of an argument with a space breaks `/select,` (#2579), so pass them as written. - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - for arg in args { - command.raw_arg(arg); - } - } - #[cfg(not(windows))] - command.args(args); - command.output().map(|o| o.status.success()).map_err(|e| e.to_string()) - }; + let run = + |program: &str, args: &[std::ffi::OsString]| reveal_process(program, args).output().map(|o| o.status.success()).map_err(|e| e.to_string()); if matches!(platform, "linux" | "freebsd") { // `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run), // so it runs off the UI thread; a failure there is only logged. @@ -83,6 +70,24 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { reveal_via(platform, &path, run) } +/// The process that runs `program` with `args`. Windows: `reveal_command` already quotes what +/// Explorer needs quoted, and Rust's own quoting of an argument with a space breaks `/select,` +/// (#2579), so the arguments go on the command line as written. +#[cfg(not(target_arch = "wasm32"))] +fn reveal_process(program: &str, args: &[std::ffi::OsString]) -> std::process::Command { + let mut command = std::process::Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + for arg in args { + command.raw_arg(arg); + } + } + #[cfg(not(windows))] + command.args(args); + command +} + #[cfg(not(target_arch = "wasm32"))] fn reveal_via( platform: &str, @@ -240,6 +245,19 @@ mod tests { assert!(reveal_via("linux", path, |_, _| Err("command missing".into())).is_err()); } + /// #2579 (review of #2625): the `/select,"…"` argument reaches the process exactly as written. + /// `cmd /c echo` prints its command line back; with Rust's own quoting (`args`) it would come + /// back wrapped in quotes with the inner ones escaped, which Explorer reads as no selection. + #[cfg(windows)] + #[test] + fn the_reveal_argument_reaches_the_process_as_written() { + let (_, args) = reveal_command("windows", std::path::Path::new(r"C:\Reports\annual report.pdf")).unwrap(); + let mut echo: Vec = ["/d", "/c", "echo"].map(std::ffi::OsString::from).to_vec(); + echo.extend(args); + let out = reveal_process("cmd", &echo).output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim_end(), r#"/select,"C:\Reports\annual report.pdf""#); + } + #[test] fn reveal_arguments_preserve_spaces_and_unicode() { let path = std::path::Path::new("/tmp/年度 report.pdf"); From 30c174be9e723ff5f9039c47eb3ee5586283c278 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:20:47 +0800 Subject: [PATCH 7/7] M3: closing PdfCraft while minimized no longer saves a 0 x 0 window eframe stores the window's size and place just before App::save. A minimized window has none: Windows reports 0 x 0 at (-32000, -32000), and the next start opened at egui's 64-point floor in the screen's corner (seen on a 6K display at 225%: a 144 x 206 px window). While the window is minimized the app now puts back the last settings it was shown with (or none), and a window that opens at that floor from settings saved before this fix is given a usable size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- apps/pdfcraft/src/main.rs | 1 + crates/ui-egui/src/lib.rs | 15 ++++ crates/ui-egui/src/window_state.rs | 137 +++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 crates/ui-egui/src/window_state.rs diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb2043..260cb835c 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -377,6 +377,7 @@ fn app_creator<'a>( if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { app.restore(&json); } + app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage); app.integrated_titlebar = integrated; app.window_controls = window_controls; app.system_text_scale = text_scale::system(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c..17f277c47 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ pub mod updates; mod wheel_pager; mod widgets; pub mod window_controls; +#[cfg(not(target_arch = "wasm32"))] +pub mod window_state; #[cfg(all(test, not(target_arch = "wasm32")))] mod open_worker_tests; @@ -744,6 +746,9 @@ pub struct PdfCraftApp { dialog_epoch: u64, /// The egui context, for commands that change window or theme state. ctx: Option, + /// The window's size and place kept across a minimized close (desktop). + #[cfg(not(target_arch = "wasm32"))] + pub window_state: window_state::WindowState, styled: bool, fonts_ready: bool, /// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`). @@ -1033,6 +1038,8 @@ impl PdfCraftApp { dialog_seen: None, dialog_epoch: 0, ctx: None, + #[cfg(not(target_arch = "wasm32"))] + window_state: Default::default(), styled: false, fonts_ready: false, fonts_cjk: theme::CjkPreference::Default, @@ -2910,11 +2917,19 @@ impl eframe::App for PdfCraftApp { } fn save(&mut self, storage: &mut dyn eframe::Storage) { + // eframe has just stored the window as it is now: not while it is minimized. + #[cfg(not(target_arch = "wasm32"))] + { + let minimized = self.ctx.as_ref().is_some_and(|c| c.input(|i| i.viewport().minimized == Some(true))); + self.window_state.keep(storage, minimized); + } storage.set_string("pdfcraft", self.persist()); } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.window_state.repair(ctx); self.dialog_epoch(); // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); diff --git a/crates/ui-egui/src/window_state.rs b/crates/ui-egui/src/window_state.rs new file mode 100644 index 000000000..0475604a4 --- /dev/null +++ b/crates/ui-egui/src/window_state.rs @@ -0,0 +1,137 @@ +//! The window's saved size and place across starts. eframe stores them just before +//! `App::save`, from the window as it is at that moment. A minimized window has neither: Windows +//! reports it as 0 × 0 at (-32000, -32000), and the next start then opened at egui's 64-point +//! floor in the screen's corner. So while the window is minimized the last settings it was shown +//! with are put back, and a window that opens at that floor is given a usable size. + +/// eframe's storage key for the window settings. +const WINDOW_KEY: &str = "window"; +/// The size egui opens a stored size below (its floor for "ridiculously small" windows), in points. +const EGUI_FLOOR: f32 = 64.0; +/// The size a window that opened at [`EGUI_FLOOR`] is given (apps/pdfcraft opens a first start at +/// this size too), at most nine tenths of the monitor. +const USABLE: egui::Vec2 = egui::vec2(1440.0, 920.0); + +#[derive(Debug, Default)] +pub struct WindowState { + /// The settings eframe last stored for a shown (not minimized) window. + shown: Option, + /// The size at start has been checked ([`Self::repair`]). + checked: bool, +} + +impl WindowState { + /// The settings the window started with: what to put back if it is minimized before eframe + /// stores a shown one. + pub fn started_with(storage: Option<&dyn eframe::Storage>) -> Self { + Self { shown: storage.and_then(|s| s.get_string(WINDOW_KEY)), checked: false } + } + + /// After eframe has stored the window: remember it while the window is shown, and while it + /// is minimized put the last shown settings back (or none, so the next start opens at the + /// default size). + pub fn keep(&mut self, storage: &mut dyn eframe::Storage, minimized: bool) { + if !minimized { + self.shown = storage.get_string(WINDOW_KEY); + } else if let Some(shown) = &self.shown { + storage.set_string(WINDOW_KEY, shown.clone()); + } else { + storage.remove_string(WINDOW_KEY); + } + } + + /// Once the window's size is known: a shown window at egui's floor in both directions came + /// from settings stored while it was minimized (before this fix), so give it a usable size. + pub fn repair(&mut self, ctx: &egui::Context) { + if self.checked { + return; + } + let (rect, minimized, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().minimized, i.viewport().monitor_size)); + let Some(rect) = rect else { return }; + if minimized == Some(true) { + return; + } + self.checked = true; + if rect.width() <= EGUI_FLOOR + 0.5 && rect.height() <= EGUI_FLOOR + 0.5 { + // The stored settings are the broken ones: never put them back. + self.shown = None; + let size = monitor.map_or(USABLE, |m| USABLE.min(m * 0.9)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::Storage as _; + + #[derive(Default)] + struct Store(std::collections::HashMap); + + impl eframe::Storage for Store { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} + } + + const SHOWN: &str = "(outer_position_pixels:Some((x:200.0,y:200.0)),inner_size_points:Some((x:1320.0,y:940.0)))"; + const MINIMIZED: &str = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),inner_size_points:Some((x:0.0,y:0.0)))"; + + /// Closing PdfCraft while it was minimized stored a 0 × 0 window off screen. + #[test] + fn a_minimized_window_keeps_the_last_shown_settings() { + let mut store = Store::default(); + store.set_string(WINDOW_KEY, SHOWN.into()); + let mut state = WindowState::started_with(Some(&store)); + // eframe stores the minimized window, then the app saves. + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY).as_deref(), Some(SHOWN)); + // Shown again and resized: that is what is kept from then on. + let moved = SHOWN.replace("200.0", "300.0"); + store.set_string(WINDOW_KEY, moved.clone()); + state.keep(&mut store, false); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), Some(moved)); + } + + #[test] + fn with_nothing_shown_yet_a_minimized_save_stores_no_window() { + let mut store = Store::default(); + let mut state = WindowState::started_with(Some(&store)); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), None, "the next start opens at the default size"); + } + + fn repaired(size: egui::Vec2, minimized: Option) -> Vec { + let ctx = egui::Context::default(); + let mut input = egui::RawInput::default(); + let info = input.viewports.entry(egui::ViewportId::ROOT).or_default(); + info.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + info.minimized = minimized; + info.monitor_size = Some(egui::vec2(2674.0, 1504.0)); + let mut state = WindowState { shown: Some(MINIMIZED.into()), checked: false }; + let out = ctx.run_logic(&input, |ctx| state.repair(ctx)); + let commands = out.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default(); + // Repaired: the settings it started with are not put back by a minimized close. + assert_eq!(state.shown.is_none(), !commands.is_empty()); + commands + } + + #[test] + fn a_window_at_eguis_floor_gets_a_usable_size() { + assert_eq!(repaired(egui::vec2(64.0, 64.0), Some(false)), [egui::ViewportCommand::InnerSize(USABLE)]); + assert!(repaired(egui::vec2(820.0, 520.0), Some(false)).is_empty(), "a small window someone chose stays"); + assert!(repaired(egui::vec2(64.0, 64.0), Some(true)).is_empty(), "not while minimized"); + } +}