From bf8d2b337b584fda57324fe653971d35233e268b Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:09:31 +0000 Subject: [PATCH 001/270] feat(ui): add optional two-paged CLI flag (#1354) --- README.md | 2 + apps/pdfcraft/src/main.rs | 40 +++------ apps/pdfcraft/src/startup.rs | 153 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 25 ++++++ crates/ui-egui/tests/editing.rs | 53 +++++++++++ 5 files changed, 246 insertions(+), 27 deletions(-) create mode 100644 apps/pdfcraft/src/startup.rs diff --git a/README.md b/README.md index b20179fff4..e3813760d8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents +Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents. + Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents. Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index c66d2c68f5..348689a5c0 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,8 @@ //! PdfCraft desktop app. //! //! Usage: `pdfcraft [options] [files…]` +//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults. +//! An explicit `--layout` takes precedence for the active document, regardless of argument order. //! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. //! //! View options (applied after the files open; also the seed of the UI control channel): @@ -37,6 +39,7 @@ mod logging; mod motion; mod reopen; mod single_instance; +mod startup; mod text_scale; mod updates; #[cfg(test)] @@ -146,28 +149,13 @@ fn main() -> eframe::Result { let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n")); } })); - let mut files = Vec::new(); - let mut options: Vec<(String, String)> = Vec::new(); - let mut control_file: Option = None; - let mut create_images = false; - let mut new_window = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--version" => { - println!("pdfcraft {}", env!("CARGO_PKG_VERSION")); - return Ok(()); - } - "--control" => control_file = args.next(), - "--create-images" => create_images = true, - "--new-window" => new_window = true, - flag if flag.starts_with("--") => { - let value = args.next().unwrap_or_default(); - options.push((flag.trim_start_matches("--").to_string(), value)); - } - _ => files.push(path_from_arg(a)), - } + let startup = startup::Arguments::parse(std::env::args().skip(1)); + if startup.version { + println!("pdfcraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); } + let may_hand_off = startup.may_hand_off(); + let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); migrate_legacy_folders(); // The log file lives in the settings folder; opened after the arguments (so `--version` leaves @@ -183,14 +171,13 @@ fn main() -> eframe::Result { // Windows: a launch that only names files (an Outlook attachment, an Explorer double-click) // hands them to the PdfCraft already running and exits (#282, #317). Any option means the // caller wants this launch's own window. - let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated }; + let launch = Launch { files, options, control_file, create_images, integrated, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -291,6 +278,7 @@ struct Launch { control_file: Option, create_images: bool, integrated: bool, + two_paged: bool, } fn app_creator<'a>( @@ -299,7 +287,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated } = launch; + let Launch { files, options, control_file, create_images, integrated, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -366,9 +354,7 @@ fn app_creator<'a>( // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } + startup::open_files(&mut app, &files, two_paged); } for (k, v) in options { if let Err(e) = app.set_option(&k, &v) { diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs new file mode 100644 index 0000000000..50ae1198d7 --- /dev/null +++ b/apps/pdfcraft/src/startup.rs @@ -0,0 +1,153 @@ +//! Native launch arguments and per-file view overrides (#1354). +use pdfcraft_ui_egui::PdfCraftApp; + +#[derive(Default)] +pub(super) struct Arguments { + pub files: Vec, + pub options: Vec<(String, String)>, + pub control_file: Option, + pub create_images: bool, + pub new_window: bool, + pub two_paged: bool, + pub version: bool, +} + +impl Arguments { + pub fn may_hand_off(&self) -> bool { + !self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged + } + + pub fn parse(args: impl IntoIterator) -> Self { + let mut parsed = Self::default(); + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--version" => { + parsed.version = true; + break; + } + "--control" => parsed.control_file = args.next(), + "--create-images" => parsed.create_images = true, + "--new-window" => parsed.new_window = true, + "--two-paged" => parsed.two_paged = true, + flag if flag.starts_with("--two-paged=") => { + // It has no value: do not consume the following filename on malformed use. + log::warn!("{flag}: use --two-paged without a value"); + } + flag if flag.starts_with("--") => { + let value = args.next().unwrap_or_default(); + parsed.options.push((flag.trim_start_matches("--").to_string(), value)); + } + _ => parsed.files.push(super::path_from_arg(a)), + } + } + parsed + } +} + +pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) { + for path in files { + if two_paged { + // The per-file override wins over initial view without changing saved defaults. + app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false); + } else { + app.open_path(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Arguments { + Arguments::parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn standalone_flag_keeps_files_in_either_position() { + for args in + [["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]] + { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["first.pdf", "second.pdf"]); + assert!(parsed.options.is_empty()); + assert!(!parsed.may_hand_off()); + } + let empty = parse(&["--two-paged"]); + assert!(empty.two_paged && empty.files.is_empty()); + let normal = parse(&["first.pdf", "second.pdf"]); + assert!(!normal.two_paged); + assert!(normal.may_hand_off()); + for args in [ + &["--new-window", "first.pdf"][..], + &["--layout", "two-page", "first.pdf"], + &["--control", "control.json", "first.pdf"], + &["--create-images", "first.png"], + ] { + assert!(!parse(args).may_hand_off()); + } + } + + #[test] + fn existing_layout_options_are_preserved_in_either_order() { + for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["file.pdf"]); + assert_eq!(parsed.options, [("layout".into(), "single".into())]); + } + let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]); + assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]); + } + + #[test] + fn malformed_boolean_does_not_eat_a_file_or_option() { + let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); + assert!(!args.two_paged); + assert_eq!(args.files, ["first.pdf", "second.pdf"]); + assert_eq!(args.options, [("layout".into(), "single".into())]); + } + + #[test] + fn startup_override_is_per_file_and_does_not_persist() { + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id())); + let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap(); + file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + drop(file); + let text = path.to_str().unwrap(); + for enabled in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "single").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + let restored = app.views[0].id; + open_files(&mut app, &[text.into(), text.into()], enabled); + assert_eq!(app.views.len(), 3); + assert_eq!(app.views[0].id, restored); + assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + for view in app.views.iter().skip(1) { + assert_eq!( + view.layout, + if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single } + ); + assert!(!view.continuous()); + } + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "single"); + assert!(persisted.get("two_paged").is_none()); + // Existing --layout applies after the flag to the active document only. + app.set_option("layout", "continuous").unwrap(); + assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(app.views[2].continuous()); + app.set_option("default-layout", "two-up").unwrap(); + assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert!(app.view_defaults.continuous); + open_files(&mut app, &[], enabled); + assert_eq!(app.views.len(), 3); + } + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4f5b0993..f165f2754f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,6 +404,8 @@ pub struct PasswordPrompt { then: Option<&'static str>, /// A link destination to apply after the linked PDF is unlocked. after_open: Option, + /// A launch-time display override, applied only after this PDF is unlocked. + display_override: Option<(canvas::PageLayout, bool)>, } /// Asks for the permissions password of an open document whose security restricts it, which @@ -1056,6 +1058,7 @@ impl PdfCraftApp { recovered: None, then: None, after_open: None, + display_override: None, }); return Ok(()); } @@ -1268,6 +1271,11 @@ impl PdfCraftApp { if let Some(destination) = after_open { self.apply_remote_destination(destination); } + if let Some((layout, continuous)) = p.display_override + && let Some(view) = self.views.last_mut() + { + view.set_display(layout, continuous); + } if let Some(meta) = &p.recovered { self.finish_recovery(meta); } @@ -1282,6 +1290,7 @@ impl PdfCraftApp { prompt.recovered = p.recovered; prompt.then = p.then; prompt.after_open = after_open; + prompt.display_override = p.display_override; } } } @@ -1401,6 +1410,22 @@ impl PdfCraftApp { } } + /// Open one native file with a transient display override, including after a password prompt. + #[cfg(not(target_arch = "wasm32"))] + pub fn open_path_with_display(&mut self, path: &str, layout: canvas::PageLayout, continuous: bool) { + let tabs = self.views.len(); + self.open_path(path); + if self.views.len() > tabs { + if let Some(view) = self.views.last_mut() { + view.set_display(layout, continuous); + } + } else if let Some(prompt) = self.password_prompt.as_mut() + && prompt.path.as_deref() == Some(path) + { + prompt.display_override = Some((layout, continuous)); + } + } + /// The File ▸ Open picker: PDFs, and images and text that become PDFs. #[cfg(not(target_arch = "wasm32"))] fn open_picker() -> rfd::AsyncFileDialog { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 56e7cce300..0e62d6f52e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1892,6 +1892,59 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } +#[test] +fn launch_display_override_survives_password_retry_without_changing_defaults() { + use pdfcraft_ui_egui::canvas::PageLayout; + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = temp_path(&format!("startup-layout-{nonce}.pdf")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + file.write_all(&protected("reader", "owner", -4)).unwrap(); + drop(file); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(Some("wrong".into())); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(Some("reader".into())); + assert_eq!(app.views.len(), 1); + assert_eq!(app.views[0].layout, PageLayout::TwoUp); + assert!(!app.views[0].continuous()); + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "continuous"); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn canceling_launch_display_override_does_not_affect_later_documents() { + use pdfcraft_ui_egui::canvas::PageLayout; + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = temp_path(&format!("startup-cancel-layout-{nonce}.pdf")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + file.write_all(&protected("reader", "owner", -4)).unwrap(); + drop(file); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(None); + assert!(app.views.is_empty() && app.password_prompt.is_none()); + app.open_bytes("next.pdf", None, fixture(2)).unwrap(); + assert_eq!(app.views[0].layout, PageLayout::Single); + assert!(app.views[0].continuous()); + // Reopening the same protected file normally must not resurrect the canceled override. + app.open_path(path.to_str().unwrap()); + app.submit_password(Some("reader".into())); + assert_eq!(app.views.len(), 2); + assert_eq!(app.views[1].layout, PageLayout::Single); + assert!(app.views[1].continuous()); + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "continuous"); + std::fs::remove_file(path).unwrap(); +} + #[test] fn password_prompt_opens_and_security_tab_reports_the_details() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { From 69f80819e1a17e8d0a58cd09fe8f1d6726c6fd95 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 21:04:06 +0100 Subject: [PATCH 002/270] M2: draw non-embedded CJK CID fonts with an installed face A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that the PDF doesn't embed drew nothing unless craft-fonts had a Japanese face: text boxes from Acrobat (Kozuka Gothic) and Pages (SongtiTC) were blank. After craft-fonts, the renderer now uses one face already installed on the machine, read at runtime and never bundled. The face of a multi-region collection (PingFang SC or TC) is chosen by region. Noto CJK and Source Han are not candidates, and PDFCRAFT_SYSTEM_FONTS=0 turns it off. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/render/Cargo.toml | 4 +- crates/render/src/lib.rs | 1 + crates/render/src/raster.rs | 61 +++++++- crates/render/src/system_font.rs | 248 +++++++++++++++++++++++++++++++ 5 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/system_font.rs diff --git a/ROADMAP.md b/ROADMAP.md index 44a26604d8..b8fc5d6956 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing. - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 28202a38b4..71467dfeb2 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -13,6 +13,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } hayro = { workspace = true } hayro-syntax = { workspace = true } +# Picks the face of an installed font collection (the version pdfcraft-fonts already uses). +skrifa = "0.44" lopdf = { workspace = true } kurbo = { workspace = true } thiserror = { workspace = true } @@ -27,7 +29,5 @@ bytemuck = { version = "1" } getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] -# Checks the Japanese substitute's character coverage; the version pdfcraft-fonts already uses. -skrifa = "0.44" # Builds the decompression-bomb streams in the raster tests. flate2 = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d2ee5712c4..90bc64b086 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ mod pixels; mod raster; mod reading; mod structure; +mod system_font; pub mod text; pub use inspect::{ diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 0e64ac241e..fe469958ae 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -73,7 +73,7 @@ impl RenderConfig { InterpreterSettings { ocg_overrides: self.layers.clone(), hide_comments: self.hide_comments, - font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| standard(query))), + font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| system_fallback(query)).or_else(|| standard(query))), ..InterpreterSettings::default() } } @@ -106,6 +106,14 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> { Some((Arc::new(face.bytes), 0)) } +/// An installed face for a CID font of an Adobe CJK collection that the PDF doesn't embed and +/// craft-fonts has no face for (Traditional and Simplified Chinese, Korean, and Japanese in a +/// build without craft-fonts). `None` for every other font. +fn system_fallback(query: &FontQuery) -> Option<(FontData, u32)> { + let FontQuery::Fallback(f) = query else { return None }; + crate::system_font::fallback(f) +} + #[derive(Debug, PartialEq)] enum JapaneseFace { Mincho, @@ -5515,6 +5523,57 @@ trailer << /Root 1 0 R >> } } + /// Chinese and Korean CID fonts that aren't embedded (Adobe-CNS1 `MSung-Light`, Adobe-GB1 + /// `STSong-Light`, Adobe-Korea1 `HYGoThic-Medium`; Acrobat also writes Japanese `KozGoPr6N` + /// text boxes without the font) drew nothing, because craft-fonts has only Japanese faces. + /// They now draw in a face already installed on the machine, read at runtime. A machine + /// without such a face draws nothing, as before, so each check is skipped there. + #[test] + fn non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face() { + use hayro::hayro_interpret::font::FallbackFontQuery; + use hayro::hayro_interpret::hayro_cmap::CharacterCollection; + // (base font, collection, ordering, encoding, UCS-2 code units of the text) + let cases = [ + ("MSung-Light", CidFamily::AdobeCNS1, "CNS1", "UniCNS-UCS2-H", "7E419AD45B57"), // 繁體字 + ("STSong-Light", CidFamily::AdobeGB1, "GB1", "UniGB-UCS2-H", "72698D448DF7"), // 物资跷 (common Simplified) + ("HYGoThic-Medium", CidFamily::AdobeKorea1, "Korea1", "UniKS-UCS2-H", "D55CAE00"), // 한글 + ]; + for (base_font, family, ordering, encoding, code) in cases { + let query = FontQuery::Fallback(FallbackFontQuery { + post_script_name: Some(base_font.into()), + character_collection: Some(CharacterCollection { family, supplement: 2 }), + ..FallbackFontQuery::default() + }); + if super::system_fallback(&query).is_none() { + eprintln!("no installed face for {base_font} (or PDFCRAFT_SYSTEM_FONTS=0): skipped"); + continue; + } + let content = format!("BT /F1 40 Tf 5 15 Td <{code}> Tj ET"); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /{encoding} /DescendantFonts [6 0 R] >> endobj +6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering ({ordering}) /Supplement 2 >> + /FontDescriptor 7 0 R /DW 1000 >> endobj +7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{base_font}: {:?}", p.error); + let inked = p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); + // Two to four glyphs at 40 pt cover several hundred dark pixels; a blank run has none. + assert!(inked > 300, "{base_font}: the text is drawn ({inked} dark pixels)"); + } + } + /// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font /// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them /// (Shippori Mincho) draws them as .notdef. diff --git a/crates/render/src/system_font.rs b/crates/render/src/system_font.rs new file mode 100644 index 0000000000..d2c98bb7cd --- /dev/null +++ b/crates/render/src/system_font.rs @@ -0,0 +1,248 @@ +//! Installed CJK faces for CID fonts a PDF doesn't embed. +//! +//! Acrobat and other tools often write Chinese, Japanese or Korean text with one of the Adobe +//! collections (Adobe-Japan1, GB1, CNS1, Korea1) and a font name such as `KozGoPr6N-Medium` or +//! `MSung-Light` without embedding the font. hayro maps those CIDs to Unicode itself, but it needs +//! a face to draw them with; without one the text is blank. The craft-fonts Japanese faces come +//! first (see `japanese_fallback` in `raster.rs`); this module is the last resort: one face +//! already installed on this machine. It is read at runtime and never embedded or shipped +//! (AGENTS.md §1.4), Noto CJK and Source Han are left out on purpose (§1.1), and +//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off, as for the interface font. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; + +use hayro::hayro_interpret::font::{FallbackFontQuery, FontData}; +use hayro::hayro_interpret::hayro_cmap::CidFamily; +use skrifa::MetadataProvider as _; +use skrifa::string::StringId; + +/// Larger files are not read: a font path is still untrusted input. CJK system faces are big +/// collections (PingFang is tens of megabytes). +const MAX_BYTES: u64 = 96 << 20; +/// Faces tried in a collection (`.ttc`). +const MAX_FACES: u32 = 32; + +/// The writing system a character collection is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Script { + Japanese, + SimplifiedChinese, + TraditionalChinese, + Korean, +} + +impl Script { + fn of(family: &CidFamily) -> Option { + match family { + CidFamily::AdobeJapan1 => Some(Self::Japanese), + CidFamily::AdobeGB1 => Some(Self::SimplifiedChinese), + CidFamily::AdobeCNS1 => Some(Self::TraditionalChinese), + CidFamily::AdobeKorea1 => Some(Self::Korean), + CidFamily::AdobeIdentity | CidFamily::Custom { .. } => None, + } + } + + /// A character the face must have to be worth using. Each is common and not shared by the + /// other scripts' faces: 物 (Japanese; also Chinese, so it only rules out Latin-only faces), + /// 欢 (Simplified only), 說 (Traditional only), 한 (Hangul). + fn probe(self) -> char { + match self { + Self::Japanese => '\u{7269}', + Self::SimplifiedChinese => '\u{6B22}', + Self::TraditionalChinese => '\u{8AAA}', + Self::Korean => '\u{D55C}', + } + } +} + +/// One installed file and the words (in a face's family name) that pick the face for the right +/// region out of a collection that holds several (PingFang SC, TC and HK share one file). +struct Candidate { + path: PathBuf, + region: &'static [&'static str], +} + +/// The installed face to draw a CID font of `query`'s collection with, as `(data, face index)`. +/// `None` when it is turned off, the collection isn't one of the four, or nothing fits. +pub(crate) fn fallback(query: &FallbackFontQuery) -> Option<(FontData, u32)> { + if cfg!(target_arch = "wasm32") || std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") { + return None; + } + let script = Script::of(&query.character_collection.as_ref()?.family)?; + let bold = query.is_bold || query.font_weight >= 600; + /// A face file and the index of the chosen face in it, or `None` when nothing fits. + type Face = Option<(Arc>, u32)>; + static CACHE: Mutex>> = Mutex::new(None); + let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner); + let found = cache.get_or_insert_with(HashMap::new).entry((script, bold)).or_insert_with(|| { + candidates(script) + .iter() + .find_map(|c| read(c, script.probe(), bold)) + .inspect(|(_, index)| log::info!("CJK font fallback for {script:?}: face {index}")) + }); + found.as_ref().map(|(bytes, index)| (Arc::clone(bytes) as FontData, *index)) +} + +fn read(candidate: &Candidate, probe: char, bold: bool) -> Option<(Arc>, u32)> { + let meta = std::fs::metadata(&candidate.path).ok()?; + if !meta.is_file() || meta.len() > MAX_BYTES { + return None; + } + let bytes = std::fs::read(&candidate.path).ok()?; + let index = pick_face(&bytes, probe, candidate.region, bold)?; + Some((Arc::new(bytes), index)) +} + +/// The best face of the file that parses and maps `probe`: the region's face when the file has +/// one, then the regular (or, for bold text, the semibold or bold) weight, then the first. +fn pick_face(bytes: &[u8], probe: char, region: &[&str], bold: bool) -> Option { + let mut best: Option<(u32, u32)> = None; + for index in 0..MAX_FACES { + let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { continue }; + if font.charmap().map(probe).is_none() { + continue; + } + let names = |id: StringId| font.localized_strings(id).map(|s| s.chars().collect::()).collect::>(); + let family = names(StringId::FAMILY_NAME).join(" "); + let style = names(StringId::SUBFAMILY_NAME).join(" ").to_ascii_lowercase(); + let mut score = 1; + if region.iter().any(|r| family.split_whitespace().any(|w| w == *r)) { + score += 4; + } + let weighty = style.contains("bold") || style.contains("semibold") || style.contains("heavy"); + let plain = style.is_empty() || style.contains("regular") || style.contains("normal") || style.contains("medium") && !bold; + if (bold && weighty) || (!bold && plain && !weighty) { + score += 2; + } + if best.is_none_or(|(_, s)| score > s) { + best = Some((index, score)); + } + } + best.map(|(index, _)| index) +} + +fn candidates(script: Script) -> Vec { + let region: &'static [&'static str] = match script { + Script::Japanese => &["JP", "Japanese"], + Script::SimplifiedChinese => &["SC", "GB"], + Script::TraditionalChinese => &["TC", "HK", "CNS"], + Script::Korean => &["KR", "Korean"], + }; + let files: Vec = if cfg!(windows) { + let dir = windows_fonts(); + let names: &[&str] = match script { + Script::Japanese => &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"], + Script::SimplifiedChinese => &["msyh.ttc", "simsun.ttc", "msjh.ttc"], + Script::TraditionalChinese => &["msjh.ttc", "mingliu.ttc", "msyh.ttc"], + Script::Korean => &["malgun.ttf", "gulim.ttc"], + }; + names.iter().map(|f| dir.join(f)).collect() + } else if cfg!(target_os = "macos") { + let names: &[&str] = match script { + Script::Japanese => &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans GB.ttc", "PingFang.ttc"], + Script::SimplifiedChinese => &["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc"], + Script::TraditionalChinese => &["PingFang.ttc", "STHeiti Light.ttc", "Hiragino Sans GB.ttc"], + Script::Korean => &["AppleSDGothicNeo.ttc", "AppleGothic.ttf", "PingFang.ttc"], + }; + names.iter().map(|f| Path::new("/System/Library/Fonts").join(f)).collect() + } else { + let names: &[&str] = match script { + Script::Korean => { + &["truetype/nanum/NanumGothic.ttf", "nanum/NanumGothic.ttf", "truetype/baekmuk/gulim.ttf", "truetype/unfonts-core/UnDotum.ttf"] + } + Script::Japanese => &[ + "truetype/fonts-japanese-gothic.ttf", + "ipa-gothic/ipag.ttf", + "truetype/takao-gothic/TakaoGothic.ttf", + "truetype/vlgothic/VL-Gothic-Regular.ttf", + ], + Script::SimplifiedChinese | Script::TraditionalChinese => &[ + "truetype/wqy/wqy-microhei.ttc", + "wenquanyi/wqy-microhei/wqy-microhei.ttc", + "wqy-microhei/wqy-microhei.ttc", + "truetype/wqy/wqy-zenhei.ttc", + "wenquanyi/wqy-zenhei/wqy-zenhei.ttc", + "wqy-zenhei/wqy-zenhei.ttc", + "truetype/droid/DroidSansFallbackFull.ttf", + "google-droid-sans-fonts/DroidSansFallbackFull.ttf", + "truetype/arphic/uming.ttc", + "arphic-uming/uming.ttc", + ], + }; + ["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| names.iter().map(move |f| Path::new(dir).join(f))).collect() + }; + files.into_iter().map(|path| Candidate { path, region }).collect() +} + +fn windows_fonts() -> PathBuf { + let dir = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + dir.join("Fonts") +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCRIPTS: [Script; 4] = [Script::Japanese, Script::SimplifiedChinese, Script::TraditionalChinese, Script::Korean]; + + #[test] + fn broken_and_missing_files_are_skipped() { + assert_eq!(pick_face(b"", '\u{7269}', &[], false), None); + assert_eq!(pick_face(b"not a font at all", '\u{7269}', &[], false), None); + assert_eq!(pick_face(&[0u8; 4096], '\u{7269}', &[], false), None); + let missing = Candidate { path: PathBuf::from("definitely/not/here.ttc"), region: &[] }; + assert!(read(&missing, '\u{7269}', false).is_none()); + // A directory is not a font. + let dir = Candidate { path: std::env::temp_dir(), region: &[] }; + assert!(read(&dir, '\u{7269}', false).is_none()); + } + + #[test] + fn a_latin_face_is_rejected_for_every_script() { + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + for script in SCRIPTS { + assert_eq!(pick_face(inter, script.probe(), &[], false), None, "{script:?}"); + } + assert_eq!(pick_face(inter, 'A', &[], false), Some(0)); + } + + #[test] + fn candidates_are_absolute_font_files_without_adobe_designs() { + for script in SCRIPTS { + let list = candidates(script); + assert!(!list.is_empty(), "{script:?}"); + for c in list { + assert!(c.path.is_absolute(), "{}", c.path.display()); + assert!(c.path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{}", c.path.display()); + let name = c.path.to_string_lossy().to_lowercase(); + assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}"); + } + } + } + + #[test] + fn only_the_four_adobe_collections_have_a_script() { + assert_eq!(Script::of(&CidFamily::AdobeCNS1), Some(Script::TraditionalChinese)); + assert_eq!(Script::of(&CidFamily::AdobeGB1), Some(Script::SimplifiedChinese)); + assert_eq!(Script::of(&CidFamily::AdobeKorea1), Some(Script::Korean)); + assert_eq!(Script::of(&CidFamily::AdobeJapan1), Some(Script::Japanese)); + assert_eq!(Script::of(&CidFamily::AdobeIdentity), None); + } + + /// On a Mac the face for Traditional Chinese is the TC one of PingFang, not the first of the + /// collection (which is another region's), and it maps a character only Traditional has. + #[cfg(target_os = "macos")] + #[test] + fn macos_picks_the_regional_pingfang_face() { + let path = Path::new("/System/Library/Fonts/PingFang.ttc"); + let Ok(bytes) = std::fs::read(path) else { return }; + for (script, region) in [(Script::TraditionalChinese, "TC"), (Script::SimplifiedChinese, "SC")] { + let index = pick_face(&bytes, script.probe(), &[region], false).expect("PingFang covers it"); + let font = skrifa::FontRef::from_index(&bytes, index).unwrap(); + let family: String = font.localized_strings(StringId::FAMILY_NAME).next().unwrap().chars().collect(); + assert!(family.contains(region), "{script:?} got {family:?}"); + } + } +} From b680ded337354487b26ff56c27b0986e21e0dfcb Mon Sep 17 00:00:00 2001 From: JM Date: Tue, 13 Oct 2026 15:38:04 +0000 Subject: [PATCH 003/270] feat: add optional page edge shadows setting --- ATTRIBUTION.toml | 42 ++-- ROADMAP.md | 4 +- crates/ui-egui/src/canvas.rs | 35 ++- crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/js_ui.rs | 4 + crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/tests/control.rs | 19 ++ crates/ui-egui/tests/page_shadows.rs | 350 +++++++++++++++++++++++++++ docs/roadmap.md | 5 +- parity/acrobat-features.toml | 4 +- 31 files changed, 473 insertions(+), 33 deletions(-) create mode 100644 crates/ui-egui/tests/page_shadows.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 28785f7675..36900e0a9b 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 = "d0a69a35403e70b94c3adfef88b41ec719f4f6ab700d351c8f82a78c53b63ef6" +sha256 = "882c0de87fd784acd0d98738d5db18dff619666fcf5d65ad49af53ae02071a52" # 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 = "caa23201b7f25dba9f8dc579771e13435e5e8c72a7efb9770e917a2a33003a39" +sha256 = "b58781b77c3c285b63f3dc455c0d59c90bc330b2680ef40484c438ebda192d8e" [[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 = "5ead9fccdca17810a3a98de3fa499d8a2469a1ff015c3d0e11880ca629295ea1" +sha256 = "a649395b81c3209dd15e647154d77fa234d57a90107dbc0c5d6b268c64194719" [[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 = "d48f9d68711850cae48af53f7691facee4209f7846e3eb2c064d97dad2ae2426" +sha256 = "d0cbf5ae09d732d3446be4f8f68ae78a1b1c0b370c199d0045d3c8522c3ad84c" [[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 = "6bc2edec99534d0df9b5e4a1c2e18dc32ad84c32e404c74dd437d86119b1d6ea" +sha256 = "232e203183548507747a128f7870a1d2b85f57198ef3cb08177021b2689f13d1" [[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 = "cb6c04bd36ba6ce0ebc808da2e47b65797e14d59466e93802adec4d4f5056e3b" +sha256 = "ea80209d0192489065fd97e4e21ea537a83e371021dff0831256ffa265ba0f4d" [[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 = "0368944155991c2ea304788418fb326f30e45649539a0cf49539194b026ca104" +sha256 = "3904d5f143a6e8861c22c5adbd2d3af3b7e2ff82d0cb40b3443180255ab55ab0" [[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 = "f4969c353f1424c6b9b5b110820c69dc59907590d013221511eae3f86c29024e" +sha256 = "ebd76484896b8931e84066acd8a4ba1c60e023cb90baffca1f0a54bd3ebc3e8f" [[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 = "4bb6fa4f3b6e236a4a353df79acd0c5528ea8354058eeaeb66c28bc0f7e02939" +sha256 = "0ad017eade954c43cd61e41342c733de46937177f07aa91f16aa8491d114904e" [[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 = "adcbdfc683589b067e062a6200084bf9192935b297f5b616f8ed39db9c71db9b" +sha256 = "bc96cc2e0ee2bb2bebc48eff1faf934d5873d443639b3b7d248b17d0d16b9977" [[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 = "aebe63cd91df2d2f9d2bdc1da43b017823dbc8a32135e999b9ac7041277be0c8" +sha256 = "5c75987e23f74c0273fd38c7af8682a5fc48aee3ceda6582b28e49ff42b4b1e1" [[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 = "3c8e3a28a5b751c6d6384809d4fd81c14c2ff5f561aaee5b58d24fbfa2ca4dc1" +sha256 = "e483d99fae3fe4fb579f3f1844903537c28d1d4acd2fcfc822f64ab77f7a4098" [[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 = "637fcfdd0bf1898b413af7ccbc2eb4228bf656273de9f3ef9241bd128940aeab" +sha256 = "e54073fd593756b00812c5bd5b951ae6e50d06d4812f988c88034f513065caef" [[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 = "5285f8987bd8c265f73ff443906c1832a0886037592d334974cf539b9f972a3f" +sha256 = "da8dff590b62692c2fd67a60e2c2c32f3093ae425946772bbc705f35f9764de2" [[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 = "23779abc08be40ddfb305fd638fe280c854ac33473b2822006ba8bad399c0cad" +sha256 = "4105b4fb3a088b34c2844edcff9e9a66f7c8dcaa6f6a45936c868dc3b24767c1" [[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 = "c5129c40b202d8467d8d4cf0a189da8c2ba2af711998edb73f67e1b291289a04" +sha256 = "97a9416f8cef0248fb3bedc2fdb38b21f6e882455255a3ff1e879652188f155b" [[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 = "11ed2aa56dc5f8f6656eba717500c74bb8c8f931c08f9f9b210b5b33dd9c2d5a" +sha256 = "17c818311e6422d4e4b49a71dcc1d784f78920b7b21b493d4e11170f7791c6e3" [[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 = "a3863e2fed5ef584a87f65083a6b9fedf26d18ecebd574efce22bd7a01e3281c" +sha256 = "7f9bc12fe0514cf61675d8537623aff84829c711d5efdb68ba4b158cd2aeb13c" [[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 = "cade94846289c2822b408a2ea23e6c8f8f321708f4f27fd563695a3c4fade6a1" +sha256 = "b666eaf326370ded831ae26a0a4e214d1e42cdf374de64f1d4244391736c47b1" [[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 = "35b6b03dae01b9a2e98f6d1f682d0699d77772e6efec7e28e1f26039c57b151f" +sha256 = "2b3a1b676f258c3903bf2447b93adcec7aadb53d3820902869301c0e5bf7f1ec" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3351,4 @@ 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 = "2685fbb694cbdeb7632ecb5e7396e1209047c83319e28ceea032b3ec9fb2fc0e" +sha256 = "f9a381fdfea0731a6913b552ea6fb209404ab27a75abe4c817e42d379091a78e" diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf35..630550c144 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; 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 (M3 #1577: optional page-edge shadows and viewport-edge fades; earlier: M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -148,6 +148,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- 2026-10-13 — M3, #1577: optional app-wide page-edge shadows and viewport-edge fades in the viewer and Organize grid; enabled by default, persisted and controllable through the UI channel. PDF rendering and edits are unchanged. + - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..8405051989 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2097,7 +2097,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let unobstructed = !app.modal_open() && !app.palette_open; let view = &mut app.views[index]; if view.organize { - organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t); return; } @@ -2382,6 +2382,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); } } + // egui paints viewport-edge gradients over the PDF after the scroll contents. + // Scope their suppression to this scroll area; other panels keep their fades. + let fade_strength = ui.spacing().scroll.fade.strength; + if !app.show_page_edge_shadows { + ui.spacing_mut().scroll.fade.strength = 0.0; + } let out = scroll.show_viewport(ui, |ui, viewport| { // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. @@ -2454,7 +2460,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; // Soft shadow + paper. - painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); + if app.show_page_edge_shadows { + painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); + } painter.rect_filled(r, CornerRadius::ZERO, Color32::WHITE); if let Some(err) = view.errors.get(&i) { painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); @@ -3142,6 +3150,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); (wanted, visible_now) }); + ui.spacing_mut().scroll.fade.strength = fade_strength; view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { // At the top once the page's top edge shows where going to a page puts it (a gap // below the window's top). Within a point, as for wheel paging, so layout rounding @@ -4240,7 +4249,17 @@ fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: Strin resp.on_hover_text(label).clicked() } -fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { +fn organize_grid( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + unobstructed: bool, + show_page_edge_shadows: bool, + ui: &mut egui::Ui, + t: &Tokens, +) { + let info = &doc.info; + let editable = doc.allows_assembly(); + let dirty = doc.dirty; let ppp = ui.ctx().pixels_per_point(); // The page image scales with the zoom; the padding and the page number don't. let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 88.0); @@ -4262,6 +4281,11 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let (mut in_view, mut sharper): (Vec<(usize, f32)>, Vec) = (Vec::new(), Vec::new()); // The source cell can scroll out of the virtualized rows during a drag. let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released()); + // As in the document viewer, hide only this area's decorative edge fades. + let fade_strength = ui.spacing().scroll.fade.strength; + if !show_page_edge_shadows { + ui.spacing_mut().scroll.fade.strength = 0.0; + } egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| { if middle_gesture { let opacity = ui.opacity(); @@ -4313,7 +4337,9 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool if view.selected.contains(&i) { ui.painter().rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } - ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + if show_page_edge_shadows { + ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + } ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE); // A page drawn larger than its thumbnail gets a render at the size drawn, while // it is in view; until that arrives the thumbnail stands in. @@ -4478,6 +4504,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool view.org_drag = None; } }); + ui.spacing_mut().scroll.fade.strength = fade_strength; // Files dragged in from outside go to the gap under the pointer. Some platforms don't say // where the pointer is until the files are dropped; then the whole grid is the target. let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| viewport.contains(*p)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index eaa102b56e..83a66ee48b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -28,6 +28,8 @@ //! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at. //! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app. //! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them. +//! - `ui.set {key: "show_page_edge_shadows", value: "true"|"false"}` controls page +//! decoration app-wide; `ui.state.show_page_edge_shadows` reports the saved preference. //! - `ui.set {key, value}`: view options (`--page`, `--zoom`, …), plus persistent preferences. //! `select` accepts physical page numbers and ranges such as `1,12,16-18`; blank clears the //! explicit selection. This is the same bounded parser as the Organize Pages field; invalid @@ -722,6 +724,7 @@ impl Host for crate::PdfCraftApp { "palette_open": self.palette_open, "theme": format!("{:?}", self.theme), "theme_preference": self.theme_preference, + "show_page_edge_shadows": self.show_page_edge_shadows, "language": self.language, "notice": self.toast.as_ref().map(|t| t.0.clone()), "progress": self.progress_notice.as_ref().map(|p| json!({ "label": p.label, "fraction": p.fraction })), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 222865d72b..c98472944a 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2373,3 +2373,4 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Show Page Edge Shadows إظهار ظلال حواف الصفحات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 17a0890fa5..8c4767391e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2315,3 +2315,4 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Show Page Edge Shadows Показване на сенки по краищата на страниците diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6e29c8bba..43cabd385e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2426,3 +2426,4 @@ animation effects Off Vypnuto This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: Move tag earlier Přesunout značku dříve Move tag later Přesunout značku později + Show Page Edge Shadows Zobrazovat stíny okrajů stránek diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38ef665d13..85cb412263 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2353,3 +2353,4 @@ animation effects Off Aus This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: Move tag earlier Tag früher verschieben Move tag later Tag später verschieben + Show Page Edge Shadows Schatten an Seitenrändern anzeigen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 106278f012..1f36c2722d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2362,3 +2362,4 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Show Page Edge Shadows Εμφάνιση σκιών στα άκρα των σελίδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5afbe4f2ef..50b132e926 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2482,3 +2482,4 @@ animation effects Off Desactivados This document is trying to open a local file: Este documento intenta abrir un archivo local: Move tag earlier Mover etiqueta antes Move tag later Mover etiqueta después + Show Page Edge Shadows Mostrar sombras en los bordes de las páginas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 79389837d3..1bbb1328b0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2352,3 +2352,4 @@ animation effects Off Désactivés This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : Move tag earlier Déplacer la balise plus tôt Move tag later Déplacer la balise plus tard + Show Page Edge Shadows Afficher les ombres des bords des pages diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3d6e1e3af5..ae0097a2c2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2351,3 +2351,4 @@ animation effects Off Ki This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: Move tag earlier Címke korábbra helyezése Move tag later Címke későbbre helyezése + Show Page Edge Shadows Oldalszéli árnyékok megjelenítése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3575ab374d..17e263f608 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2361,3 +2361,4 @@ animation effects Off Disattivati This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: Move tag earlier Sposta tag prima Move tag later Sposta tag dopo + Show Page Edge Shadows Mostra le ombre ai bordi delle pagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e61802c35..7ff964ec3a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2352,3 +2352,4 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Show Page Edge Shadows ページ端の影を表示 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 8e4a090403..b472b97c06 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2350,3 +2350,4 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Show Page Edge Shadows Бет жиектерінің көлеңкелерін көрсету diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ae61e396ff..20ffdcf1d6 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2596,3 +2596,4 @@ key Space Tarpas This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: Move tag earlier Perkelti žymą anksčiau Move tag later Perkelti žymą vėliau + Show Page Edge Shadows Rodyti puslapių kraštų šešėlius diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 310a990f7d..c2011e70b4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2332,3 +2332,4 @@ animation effects Off Wyłączone This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny: Move tag earlier Przenieś znacznik wcześniej Move tag later Przenieś znacznik później + Show Page Edge Shadows Pokaż cienie krawędzi stron diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 623d69ce80..41c94d1ca1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2366,3 +2366,4 @@ animation effects Off Desativados This document is trying to open a local file: Este documento está tentando abrir um arquivo local: Move tag earlier Mover tag para antes Move tag later Mover tag para depois + Show Page Edge Shadows Mostrar sombras nas bordas das páginas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7f628088d6..d67a507eab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2265,3 +2265,4 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Show Page Edge Shadows Показывать тени по краям страниц diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3feaad354d..d7279724c5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2357,3 +2357,4 @@ animation effects Off Izklopljeni This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: Move tag earlier Premakni oznako prej Move tag later Premakni oznako pozneje + Show Page Edge Shadows Prikaži sence robov strani diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index d15365f3d4..04368336bf 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2265,3 +2265,4 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Show Page Edge Shadows పేజీ అంచుల నీడలను చూపించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 44fd05f074..65e442547d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2348,3 +2348,4 @@ animation effects Off Kapalı This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: Move tag earlier Etiketi daha erken taşı Move tag later Etiketi daha geç taşı + Show Page Edge Shadows Sayfa kenarı gölgelerini göster diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 648dc855fe..b2e7bd576a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2374,3 +2374,4 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Show Page Edge Shadows Показувати тіні на краях сторінок diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 391c8eda7c..a67b93e1b4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2316,3 +2316,4 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Show Page Edge Shadows 显示页面边缘阴影 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da45921c21..af726d4b01 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2226,3 +2226,4 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Show Page Edge Shadows 顯示頁面邊緣陰影 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index fe474622c7..48ded85723 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -263,6 +263,10 @@ fn reading_mode_label(mode: &str) -> &'static str { /// Preferences: interface language and size, appearance, identity and JavaScript. Returns `true` to close. pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(13.0))); + if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { + ui.ctx().request_repaint(); + } ui.horizontal(|ui| { ui.label(tl!("Interface language")); let selected = crate::i18n::Lang::from_code(&app.language).map_or(tl!("Auto"), crate::i18n::Lang::name); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea3da00957..276aa56b70 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -511,6 +511,8 @@ pub struct PdfCraftApp { /// The reading-colours preference (Preferences ▸ Reading colours): applied to every open /// document, and documents open with it (Acrobat's Replace Document Colors). pub reading_colors: pdfcraft_render::ReadingColors, + /// App-wide page decoration only; never part of a PDF edit or render request. + pub show_page_edge_shadows: bool, /// The Linux portal is enabled only by the native host, and starts on the first System choice. #[cfg(target_os = "linux")] desktop_theme: Option, @@ -882,6 +884,7 @@ impl PdfCraftApp { theme: ThemeKind::Light, theme_preference: ThemePreference::Light, reading_colors: Default::default(), + show_page_edge_shadows: true, #[cfg(target_os = "linux")] desktop_theme: None, #[cfg(target_os = "linux")] @@ -2074,7 +2077,7 @@ impl PdfCraftApp { /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); - serde_json::json!({ + let mut settings = serde_json::json!({ "docking": self.docking, "docking_left_open": self.left_open, "docking_right": self.right, @@ -2133,8 +2136,12 @@ impl PdfCraftApp { "combine_zoom": self.combine_zoom, "comments_panel_closed": self.comments_panel_closed, "right_panel_closed": self.right_panel_closed, - }) - .to_string() + }); + // Add this separately to keep the existing settings macro below its expansion limit. + if let Some(settings) = settings.as_object_mut() { + settings.insert("show_page_edge_shadows".into(), self.show_page_edge_shadows.into()); + } + settings.to_string() } /// Restore state written by `persist`. Unknown or malformed data is ignored. @@ -2212,6 +2219,9 @@ impl PdfCraftApp { if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) { self.fill_text_size = size; } + if let Some(show) = v["show_page_edge_shadows"].as_bool() { + self.show_page_edge_shadows = show; + } // Settings are untrusted: an unreadable value (a colour that isn't three bytes, say) // fails to deserialize and keeps Off. if let Ok(colors) = serde_json::from_value::(v["reading_colors"].clone()) { @@ -2316,6 +2326,9 @@ impl PdfCraftApp { }; self.set_theme_preference(preference); } + ("show_page_edge_shadows", _) => { + self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?; + } ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index a73b3073b2..723f3910a9 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -73,6 +73,25 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}")) } +#[test] +fn page_shadows_are_an_observable_app_wide_preference() { + let (mut h, c) = harness(); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], true); + for value in ["false", "true"] { + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": value })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], value == "true"); + } + for value in ["off", "1", "False", ""] { + assert!(call(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": value })).is_err()); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], true); + } + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": false })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], false); + h.state_mut().active = None; + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": "false" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], false); +} + #[test] fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/page_shadows.rs b/crates/ui-egui/tests/page_shadows.rs new file mode 100644 index 0000000000..9cdee3f7a7 --- /dev/null +++ b/crates/ui-egui/tests/page_shadows.rs @@ -0,0 +1,350 @@ +//! Decorative page shadows are app preferences, never PDF edits. + +use egui_kittest::{Harness, kittest::Queryable}; +use pdfcraft_ui_egui::PdfCraftApp; + +#[test] +fn viewer_shadows_follow_the_preference_without_moving_pages() { + for theme in ["light", "dark"] { + for layout in ["single", "two-page", "continuous", "two-up"] { + let mut h = harness(theme); + h.state_mut().set_option("layout", layout).unwrap(); + h.run_steps(3); + let rectangles = h.state().views[0].visible_page_rects().to_vec(); + let original = shadows(&h, false); + assert!(!original.is_empty(), "{theme} {layout}"); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(shadows(&h, false).is_empty(), "{theme} {layout}"); + assert_eq!(h.state().views[0].visible_page_rects(), rectangles); + h.state_mut().set_option("zoom", "125").unwrap(); + h.state_mut().set_option("page", "2").unwrap(); + h.run_steps(3); + assert!(shadows(&h, false).is_empty()); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert!(!shadows(&h, false).is_empty()); + } + } +} + +#[test] +fn viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled() { + for theme in ["light", "dark"] { + for organize in [false, true] { + let mut h = harness(theme); + if organize { + h.state_mut().set_option("organize", "on").unwrap(); + // Make the three-page grid overflow vertically. + h.state_mut().set_option("grid-zoom", "300").unwrap(); + } + h.run_steps(4); + let original_style = h.ctx.global_style().spacing.scroll.fade; + assert!(original_style.strength > 0.0); + assert!(!viewport_fades(&h).is_empty(), "bottom fade at start: {theme}, organize={organize}"); + scroll_down(&mut h, 100.0); + let original = viewport_fades(&h); + let viewport = h.state().views[0].viewport_rect(); + assert!(original.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0), "top fade: {original:?}"); + assert!(original.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "bottom fade: {original:?}"); + let pages = h.state().views[0].visible_page_rects().to_vec(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(viewport_fades(&h).is_empty(), "viewport fades remain: {theme}, organize={organize}"); + assert_eq!(h.state().views[0].visible_page_rects(), pages); + assert_eq!(h.ctx.global_style().spacing.scroll.fade, original_style); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert_eq!(viewport_fades(&h), original); + scroll_down(&mut h, 100_000.0); + let at_end = viewport_fades(&h); + assert!(at_end.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0)); + assert!(!at_end.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "no bottom fade at the end"); + } + } +} + +#[test] +fn horizontal_viewport_fades_also_follow_the_preference() { + let mut h = harness("dark"); + h.state_mut().set_option("zoom", "400").unwrap(); + h.run_steps(4); + let viewport = h.state().views[0].viewport_rect(); + let horizontal: Vec<_> = viewport_fades(&h).into_iter().filter(|r| r.width() <= 21.0).collect(); + assert!(horizontal.iter().any(|r| (r.left() - viewport.left()).abs() < 2.0), "left fade: {horizontal:?}"); + assert!(horizontal.iter().any(|r| (r.right() - viewport.right()).abs() < 2.0), "right fade: {horizontal:?}"); + let pages = h.state().views[0].visible_page_rects().to_vec(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(viewport_fades(&h).is_empty()); + assert_eq!(h.state().views[0].visible_page_rects(), pages); +} + +fn scroll_down(h: &mut Harness<'static, PdfCraftApp>, points: f32) { + h.hover_at(h.state().views[0].viewport_rect().center()); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -points), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(12); +} + +/// Inspect the actual egui painter output: untextured four-vertex gradients +/// with a transparent edge, spanning one dimension of the document viewport. +fn viewport_fades(h: &Harness<'static, PdfCraftApp>) -> Vec { + let viewport = h.state().views[0].viewport_rect(); + h.output() + .shapes + .iter() + .filter_map(|s| { + let egui::Shape::Mesh(mesh) = &s.shape else { return None }; + if mesh.texture_id != egui::TextureId::default() || mesh.vertices.len() != 4 || mesh.indices.len() != 6 { + return None; + } + let rect = mesh.calc_bounds(); + let transparent = mesh.vertices.iter().filter(|v| v.color == egui::Color32::TRANSPARENT).count(); + (transparent == 2 + && (((rect.width() - viewport.width()).abs() < 2.0 && rect.height() <= 21.0) + || ((rect.height() - viewport.height()).abs() < 2.0 && rect.width() <= 21.0)) + && rect.intersects(viewport)) + .then_some(rect) + }) + .collect() +} + +#[test] +fn organize_shadows_follow_the_preference() { + for theme in ["light", "dark"] { + let mut h = harness(theme); + h.state_mut().set_option("organize", "on").unwrap(); + h.run_steps(3); + let original = shadows(&h, true); + assert_eq!(original.len(), 3); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(shadows(&h, true).is_empty()); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert_eq!(shadows(&h, true), original); + } +} + +#[test] +fn checkbox_and_multiple_documents_do_not_edit_pdf_state() { + let mut h = harness("light"); + h.state_mut().open_bytes("second.pdf", None, pdf()).unwrap(); + let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect(); + h.state_mut().session.apply(ids[0], pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Keep existing edits".into() }).unwrap(); + assert!(h.state().session.get(ids[0]).unwrap().dirty); + assert!(h.state().session.get(ids[0]).unwrap().can_undo().is_some()); + let saved: Vec<_> = ids.iter().map(|id| h.state_mut().session.save_bytes(*id).unwrap()).collect(); + let before: Vec<_> = h + .state() + .views + .iter() + .map(|v| { + let d = h.state().session.get(v.id).unwrap(); + ( + d.bytes.clone(), + d.dirty, + d.can_undo().map(str::to_owned), + d.can_redo().map(str::to_owned), + d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::>(), + ) + }) + .collect(); + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(3); + let checkbox = h.get_by_label("Show Page Edge Shadows"); + checkbox.click(); + h.run_steps(2); + assert_eq!(serde_json::from_str::(&h.state().persist()).unwrap()["show_page_edge_shadows"], false); + h.get_by_label("Show Page Edge Shadows").focus(); + h.key_press(egui::Key::Space); + h.run_steps(2); + assert_eq!(serde_json::from_str::(&h.state().persist()).unwrap()["show_page_edge_shadows"], true); + let dialog_shadow = h.ctx.global_style().visuals.window_shadow; + assert_ne!(dialog_shadow, egui::Shadow::NONE); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert_eq!(h.ctx.global_style().visuals.window_shadow, dialog_shadow); + h.state_mut().set_option("dialog", "none").unwrap(); + for tab in [0, 1] { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(shadows(&h, false).is_empty()); + let d = h.state().session.get(h.state().views[tab].id).unwrap(); + assert_eq!( + (&d.bytes, d.dirty, d.can_undo(), d.can_redo()), + (&before[tab].0, before[tab].1, before[tab].2.as_deref(), before[tab].3.as_deref()) + ); + assert_eq!(d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::>(), before[tab].4); + } + for (id, bytes) in ids.iter().zip(&saved) { + assert_eq!(h.state_mut().session.save_bytes(*id).unwrap(), *bytes); + } + h.state_mut().open_bytes("new.pdf", None, pdf()).unwrap(); + h.run_steps(2); + assert!(shadows(&h, false).is_empty()); +} + +#[test] +fn rendered_page_content_is_identical_with_and_without_decoration() { + let mut h = harness("dark"); + h.state_mut().set_option("panel", "pages").unwrap(); + h.state_mut().set_option("zoom", "175").unwrap(); + h.run_steps(3); + scroll_down(&mut h, 20.0); + for _ in 0..400 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().render_pending(), "the actual PDF raster must be ready"); + let rect = h.state().views[0].page_screen_rect(0).unwrap(); + let fades = viewport_fades(&h); + assert_eq!(fades.iter().filter(|r| r.height() <= 21.0).count(), 2, "the screenshot comparison must contain both vertical fades"); + let enabled = h.render().unwrap(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + let disabled = h.render().unwrap(); + assert_eq!(h.state().views[0].page_screen_rect(0), Some(rect)); + let viewport = h.state().views[0].viewport_rect(); + assert!(viewport_fades(&h).is_empty()); + // Only the painter's actual decorative fade strips may change over the page. + // All remaining artwork, annotation and form pixels must be identical. + for (_, page) in h.state().views[0].visible_page_rects() { + let visible = page.intersect(viewport).shrink(2.0); + if !visible.is_positive() { + continue; + } + for y in visible.top().ceil() as u32..visible.bottom().floor() as u32 { + for x in visible.left().ceil() as u32..visible.right().floor() as u32 { + if !fades.iter().any(|fade| fade.expand(1.0).contains(egui::pos2(x as f32, y as f32))) { + assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "page content at {x},{y}"); + } + } + } + } + // The top overlaps the first page's blue artwork; the bottom overlaps the + // blank second page. Sample clear of the unchanged scrollbar at the edge. + let x = rect.intersect(viewport).center().x as u32; + for (y, expected) in [(viewport.top() + 8.0, [204, 230, 255, 255]), (viewport.bottom() - 10.0, [255, 255, 255, 255])] { + assert_eq!(disabled.get_pixel(x, y as u32).0, expected, "unobscured PDF at y={y}"); + assert_ne!(enabled.get_pixel(x, y as u32).0, expected, "fade overlays the PDF at y={y}"); + } + // The Pages sidebar, including its thumbnails and selection borders, is unaffected. + for y in 0..enabled.height() { + for x in (0..viewport.left().floor() as u32).chain(viewport.right().ceil() as u32..enabled.width()) { + assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "sidebar at {x},{y}"); + } + } + assert_ne!(enabled.as_raw(), disabled.as_raw(), "the decoration really changes the image"); + if let Ok(directory) = std::env::var("PDFCRAFT_SHADOW_SCREENSHOTS") { + enabled.save(format!("{directory}/page-shadows-enabled.png")).unwrap(); + disabled.save(format!("{directory}/page-shadows-disabled.png")).unwrap(); + } +} + +fn harness(theme: &str) -> Harness<'static, PdfCraftApp> { + let theme = theme.to_string(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_option("theme", &theme).unwrap(); + app.open_bytes("pages.pdf", None, pdf()).unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(3); + h +} + +fn shadows(h: &Harness<'static, PdfCraftApp>, grid: bool) -> Vec { + let color = pdfcraft_ui_egui::theme::Tokens::for_kind(h.state().theme).page_shadow; + fn collect(shape: &egui::Shape, color: egui::Color32, grid: bool, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => { + for s in shapes { + collect(s, color, grid, out); + } + } + egui::Shape::Rect(r) + if r.fill == color + && r.rect.width() > 20.0 + && r.rect.height() > 20.0 + && if grid { + r.blur_width == 0.0 && r.corner_radius == egui::CornerRadius::same(1) + } else { + r.blur_width == 14.0 && r.corner_radius == egui::CornerRadius::ZERO + } => + { + out.push(r.rect) + } + _ => {} + } + } + let mut out = Vec::new(); + for s in &h.output().shapes { + collect(&s.shape, color, grid, &mut out); + } + out +} + +/// Synthetic pages include colored artwork, an annotation and a form field. +fn pdf() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Annots [6 0 R 7 0 R] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (Note) >>".to_string(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (field) /V (value) /Rect [50 50 100 70] /P 3 0 R >>".to_string(), + { + let body = "0.8 0.9 1 rg 0 0 300 400 re f 0.3 g 10 10 280 8 re f"; + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()) + }, + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[test] +fn page_shadow_preference_round_trips_and_ignores_invalid_settings() { + let mut app = PdfCraftApp::new(); + let enabled = |app: &PdfCraftApp| serde_json::from_str::(&app.persist()).unwrap()["show_page_edge_shadows"].clone(); + assert_eq!(enabled(&app), true); + app.restore("{}"); + assert_eq!(enabled(&app), true); + for value in [false, true] { + app.set_option("show_page_edge_shadows", if value { "true" } else { "false" }).unwrap(); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert_eq!(enabled(&restored), value); + } + for invalid in ["null", "0", "\"false\"", "[]", "{}"] { + app.restore(&format!("{{\"show_page_edge_shadows\":{invalid}}}")); + assert_eq!(enabled(&app), true); + } + app.restore("not json"); + assert_eq!(enabled(&app), true); + assert!(app.set_option("show_page_edge_shadows", "maybe").is_err()); + assert_eq!(enabled(&app), true); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 04103fb632..354689402b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; 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 (M3 #1577: optional page-edge shadows and viewport-edge fades; earlier: M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -90,7 +90,7 @@ total matches the parity estimate. | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | 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. | | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. | +| 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. Preferences ▸ Appearance can hide decorative page-edge shadows and viewport-edge fades in the viewer and Organize grid (#1577); enabled by default, persisted app-wide and exposed through UI control. | | 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. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; 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). | @@ -161,6 +161,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M3 #1577: optional decorative page-edge shadows and viewport-edge fades; no PDF-content or readiness-percentage change. | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b932..d527dadf7f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9792,8 +9792,8 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom"] -notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it. Missing: Acrobat's resolution, rendering and page-content options" +evidence = ["crates/ui-egui/tests/page_shadows.rs::viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled", "crates/ui-egui/tests/page_shadows.rs::rendered_page_content_is_identical_with_and_without_decoration", "crates/ui-egui/tests/page_shadows.rs::page_shadow_preference_round_trips_and_ignores_invalid_settings", "crates/ui-egui/tests/page_shadows.rs::viewer_shadows_follow_the_preference_without_moving_pages", "crates/ui-egui/tests/page_shadows.rs::organize_shadows_follow_the_preference", "crates/ui-egui/tests/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom"] +notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it. Preferences ▸ Appearance also has an app-wide, persistent Show Page Edge Shadows checkbox (on by default), exposed through ui.set and ui.state; it affects viewer and Organize decorations, including the viewport-edge gradients painted over pages, only. Missing: Acrobat's resolution, rendering and page-content options" [[feature]] id = "misc.prefs-documents" From fc14719dff434e692123cb19b8c94e07a2d314cc Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:18:41 +0200 Subject: [PATCH 004/270] feat(forms): edit checkbox and radio export values (#231) --- ROADMAP.md | 2 ++ crates/automation/README.md | 12 ++++++++ crates/automation/src/forms.rs | 17 +++++++++- crates/automation/src/tools.rs | 4 ++- crates/automation/tests/form_bulk.rs | 23 ++++++++++++++ crates/forms/README.md | 13 ++++++++ crates/forms/src/author.rs | 46 ++++++++++++++++++++++++++++ crates/forms/src/lib.rs | 9 ++++++ crates/forms/src/tests.rs | 39 +++++++++++++++++++++++ crates/ui-egui/src/prepare.rs | 8 ++++- crates/ui-egui/tests/prepare.rs | 33 ++++++++++++++++++++ crates/xfdf/src/lib.rs | 13 +++++--- crates/xfdf/src/tests.rs | 33 ++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 14 files changed, 246 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf35..3013de8b1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6 #231: edit checkbox/radio export values through individual field properties and automation, preserving selection and appearance; headless regression coverage, desktop visual verification pending; estimates unchanged) + **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) diff --git a/crates/automation/README.md b/crates/automation/README.md index f2e4ec9282..f20ad35ea1 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -184,3 +184,15 @@ unapplied with a warning. `doc_batch` runs conversion, OCR and compression over files and optionally recursive folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. + +## Editing button export values + +Use `form_set_props` with `field`, `export_value` and optional `widget` +(1-based, default 1) to change a checkbox or radio button export value. +`form_fields` returns `export_values` in widget order. The selected/default +appearance states are preserved; the operation is undoable and survives saving. +Widgets sharing an appearance state keep one export value. Bulk edits are not +supported for this widget-specific property. Empty values, `Off`, conflicting +choices and values over 4096 UTF-8 bytes return an error. + +Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 7c60425a8c..6089d5a36a 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -232,6 +232,9 @@ impl Automation { if !f.actions.unsupported.is_empty() { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } + if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) { + o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::>())); + } match f.kind { FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); @@ -327,6 +330,9 @@ impl Automation { } pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { + if a.get("widget").is_some() && a.get("export_value").is_none() { + return Err(bad("widget requires export_value")); + } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); if single.is_some() == bulk { @@ -344,7 +350,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation"] { + for key in ["name", "rect", "rotation", "export_value", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -396,6 +402,15 @@ impl Automation { }; Ok(FieldProps { rect, + export_value: a + .opt_str("export_value")? + .map(|value| -> Result<_> { + let index = a.opt_int("widget")?.unwrap_or(1); + let index = + usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((index, value.to_owned())) + }) + .transpose()?, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), read_only: a.opt_bool("read_only")?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..be85f00077 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -364,7 +364,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and export_value require a single field. export_value edits a checkbox or radio widget (widget: 1-based, default 1), preserving its selected/default state; widgets sharing that state keep the same export value. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with({ let mut input = schema( @@ -373,6 +373,8 @@ pub fn tools() -> Vec { "field": { "type": "string", "description": "The field's current name." }, "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, + "export_value": { "type": "string", "minLength": 1, "description": "Checkbox/radio export value; not Off; at most 4096 UTF-8 bytes." }, + "widget": { "type": "integer", "minimum": 1, "description": "1-based widget for export_value (default 1)." }, "tooltip": { "type": "string" }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/form_bulk.rs index 8b8904c974..4296cbc891 100644 --- a/crates/automation/tests/form_bulk.rs +++ b/crates/automation/tests/form_bulk.rs @@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() { assert_eq!(bytes(&a, id), before); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn button_export_edit_is_one_undo_step_and_survives_save() { + let (mut a, id, dir) = setup("button-export"); + tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]})); + tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}})); + let before = bytes(&a, id); + call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1})); + let f = tool(&mut a, "form_fields", json!({"doc": id})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(bytes(&a, id), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"})); + let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + let f = tool(&mut a, "form_fields", json!({"doc": reopened})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 3280cc2431..bc8e19fc95 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -57,6 +57,19 @@ delete_field(&mut doc, "email")?; buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +## Button export values + +`FieldProps::export_value = Some((widget_index, value))` edits a checkbox or radio +button's export value. The API index is zero-based. It writes a complete `/Opt` +mapping, preserving appearance names, selected states and reset defaults; an +export-only change leaves custom appearance streams intact. Widgets sharing an +appearance state keep the same export value. Conflicting shared-state mappings, +ambiguous choices, invalid widget indices, empty values, `Off`, and values over +4096 UTF-8 bytes are refused. + +`Field::export_values` resolves button values for data exchange. An unchecked +button still exports as `Off` in the data formats. + ## Not yet Rich-text values and exact appearance metrics/layout remain incomplete. JavaScript diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 6d49be1001..a8c99cb483 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -68,6 +68,9 @@ pub struct FieldProps { pub max_len: Option>, /// Choice options (display texts; export values equal them). pub options: Option>, + /// Change one button widget's export value without changing its appearance state. + /// Widgets sharing that state are kept in sync. The index is zero-based. + pub export_value: Option<(usize, String)>, /// Font size (0 = auto). pub font_size: Option, /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in @@ -845,6 +848,36 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out) } +/// Build a complete /Opt mapping before modifying the document. Appearance names, +/// /V and /DV stay untouched, so an export-value edit does not change selection. +fn button_export_options(f: &Field, index: usize, value: &str) -> Result, FormError> { + if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have an export value"); + } + if value.is_empty() || value == "Off" || value.len() > 4096 { + return invalid("export value must be non-empty, at most 4096 bytes, and not Off"); + } + let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?; + let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + if !f.options.is_empty() && f.options.len() != f.widgets.len() { + return invalid("button export options do not match its widgets"); + } + let previous = f.export_of(index); + let mut out = Vec::with_capacity(f.widgets.len()); + for (i, w) in f.widgets.iter().enumerate() { + let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + let same_state = w.on_state.as_deref() == Some(state); + if !same_state && (export == value || w.on_state.as_deref() == Some(value)) { + return invalid("export value conflicts with another button choice"); + } + if same_state && Some(export) != previous { + return invalid("widgets with the same appearance state have conflicting export values"); + } + out.push(Object::String(PdfString::text(if same_state { value } else { export }))); + } + Ok(out) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -858,6 +891,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; @@ -879,6 +916,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Vec { + if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) { + self.value.iter().map(|v| self.export_for_state(v).to_string()).collect() + } else { + self.value.clone() + } + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 0256bfafb9..e3fa748651 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1943,3 +1943,42 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); assert!(filled.rules.is_empty()); } + +#[test] +fn editing_button_export_values_preserves_selection_and_appearance() { + let mut doc = fixture(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap(); + let before = field(&fields(&doc), "size").clone(); + let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect(); + set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap(); + let after = field(&fields(&doc), "size").clone(); + assert_eq!(after.value, before.value); + assert_eq!(after.default, before.default); + assert_eq!(after.widgets, before.widgets); + assert_eq!(after.export_of(0), Some("Small å")); + assert_eq!(after.export_of(1), before.export_of(1)); + assert_eq!(after.export_values(), vec!["Small å"]); + assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::>()); + let mut saved = reopen(&doc); + assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å")); + set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap(); + reset(&mut saved, None).unwrap(); + assert_eq!(field(&fields(&saved), "size").value, before.default); + + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + let check = field(&fields(&doc), "agree").clone(); + assert_eq!(check.value, vec!["Yes"]); + assert_eq!(check.export_values(), vec!["Accepted"]); + assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted")); +} + +#[test] +fn invalid_button_export_edits_leave_document_unchanged() { + let mut doc = fixture(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] { + assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 37747f14c7..762d090254 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -758,6 +758,7 @@ pub struct FieldDraft { pub default: String, /// Check boxes and radio buttons: this widget's "on" state (export value). pub on_state: String, + pub export_value: String, /// 0 = auto. pub font_size: f64, /// Left, bottom, width, height in points. @@ -824,6 +825,7 @@ impl FieldDraft { quadding: f.quadding, default: f.default.first().cloned().unwrap_or_default(), on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(), + export_value: f.export_of(widget).unwrap_or_default().to_string(), font_size: da_size(&f.da), position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0), @@ -877,6 +879,7 @@ impl FieldDraft { multiline: ch(self.multiline, o.multiline), max_len: ((self.limit, self.max_len) != (o.limit, o.max_len)).then_some((self.limit && self.max_len > 0).then_some(self.max_len)), options: (self.options != o.options).then(|| self.options.clone()), + export_value: (self.export_value != o.export_value).then(|| (self.widget, self.export_value.clone())), font_size: ((self.font_size - o.font_size).abs() > 1e-6).then_some(self.font_size), rect: (self.position != o.position).then(|| { let [x, y, w, h] = self.position; @@ -1123,7 +1126,10 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke FormFieldKind::CheckBox | FormFieldKind::Radio => { use pdfcraft_engine::field_flags as ff; let on = d.on_state.clone(); - ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", &on)])); + ui.horizontal(|ui| { + let label = ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", "")])); + ui.text_edit_singleline(&mut d.export_value).labelled_by(label.id); + }); let mut checked = !d.default.is_empty() && d.default == on; let label = if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") }; diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index b23f1a9f8f..81359d8bc2 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -497,3 +497,36 @@ fn actions_tab_adds_and_removes_actions() { ] ); } + +#[test] +fn button_export_properties_apply_cancel_and_undo() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.state_mut().open_field_props("agree", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + let original = h.state().field_props.as_ref().unwrap().export_value.clone(); + h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("Cancel").click(); + h.run_steps(2); + h.state_mut().open_field_props("agree", 0); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original); + h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("OK").click(); + h.run_steps(3); + { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap(); + assert_eq!(f.export_of(0), Some("Accepted")); + } + h.state_mut().execute("edit.undo"); + h.run_steps(3); + { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap(); + assert_eq!(f.export_of(0), Some(original.as_str())); + } +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d9287101ba..9b1b439caf 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -420,7 +420,7 @@ fn xfdf_fields(doc: &Document, out: &mut String) { } node.values = match f.kind { FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()], - _ => f.value.clone(), + _ => f.export_values(), }; } fn write_node(name: &str, node: &Node, out: &mut String) { @@ -480,7 +480,7 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V body.extend_from_slice(b" /V "); match f.kind { FieldKind::CheckBox | FieldKind::Radio => { - let v = f.value.first().cloned().unwrap_or_else(|| "Off".into()); + let v = f.export_values().first().cloned().unwrap_or_else(|| "Off".into()); pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body); } FieldKind::List if f.value.len() > 1 => { @@ -575,7 +575,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { .map(|f| { let v = match f.kind { FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => "Off".to_string(), - _ => f.value.join(", "), + _ => f.export_values().join(", "), }; (f.name, v) }) @@ -984,8 +984,11 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError .into_iter() .filter(|f| !matches!(f.kind, FieldKind::PushButton | FieldKind::Signature)) .map(|f| { - let v = - if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { vec!["Off".to_string()] } else { f.value }; + let v = if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { + vec!["Off".to_string()] + } else { + f.export_values() + }; (f.name, v) }) .collect()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index be747d77e1..d61273f1b5 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -887,3 +887,36 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}"); } } + +#[test] +fn edited_button_export_values_round_trip_in_all_data_formats() { + use pdfcraft_forms::{FieldProps, set_props}; + let mut doc = blank(); + add_field(&mut doc, 0, [80.0, 650.0, 94.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "S".into() }, None).unwrap(); + add_field(&mut doc, 0, [100.0, 650.0, 114.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "L".into() }, None).unwrap(); + set_props(&mut doc, "Agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + set_props(&mut doc, "Size", &FieldProps { export_value: Some((0, "Small".into())), ..Default::default() }).unwrap(); + set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap(); + set_value(&mut doc, "Size", &FieldValue::Radio(Some("Small".into()))).unwrap(); + let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let mut exports = vec![export_xfdf(&doc, false, true, "").into_bytes(), export_fdf(&doc, false, true, "")]; + exports.extend([Format::Xml, Format::Csv, Format::Txt].map(|f| export_data(&doc, f).into_bytes())); + for (index, bytes) in exports.into_iter().enumerate() { + if index < 2 { + let values = data_values(&bytes).unwrap(); + assert_eq!(values.iter().find(|(n, _)| n == "Agree").unwrap().1, vec!["Accepted"]); + assert_eq!(values.iter().find(|(n, _)| n == "Size").unwrap().1, vec!["Small"]); + } else { + // data_values reads XFDF/FDF/PDF; import also handles XML/CSV/TXT. + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains("Accepted") && text.contains("Small"), "{text}"); + } + let mut target = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + set_value(&mut target, "Agree", &FieldValue::Check(false)).unwrap(); + set_value(&mut target, "Size", &FieldValue::Radio(Some("L".into()))).unwrap(); + import(&mut target, &bytes).unwrap(); + let fs = fields(&target); + assert_eq!(fs.iter().find(|f| f.name == "Agree").unwrap().export_values(), vec!["Accepted"]); + assert_eq!(fs.iter().find(|f| f.name == "Size").unwrap().export_values(), vec!["Small"]); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b932..f5189bc33e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4896,8 +4896,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] -notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::editing_button_export_values_preserves_selection_and_appearance", "crates/forms/src/tests.rs::invalid_button_export_edits_leave_document_unchanged", "crates/xfdf/src/tests.rs::edited_button_export_values_round_trip_in_all_data_formats", "crates/automation/tests/form_bulk.rs::button_export_edit_is_one_undo_step_and_survives_save", "crates/ui-egui/tests/prepare.rs::button_export_properties_apply_cancel_and_undo"] +notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison, individual widget export value via /Opt without changing selection or appearance; XFDF/FDF/XML/CSV/TXT exports use the mapped values. Export editing has headless Apply/Cancel/Undo and save/reopen coverage; desktop typing/appearance has not been visually verified. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions" From 0e8d4d70fabbdc4f030af86f801604d1db5766a6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:31:14 +0200 Subject: [PATCH 005/270] test(forms): type export values through the properties widget --- crates/ui-egui/tests/prepare.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 81359d8bc2..896eeff196 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -500,6 +500,14 @@ fn actions_tab_adds_and_removes_actions() { #[test] fn button_export_properties_apply_cancel_and_undo() { + fn retype(h: &mut Harness<'static, PdfCraftApp>, value: &str) { + use egui::accesskit::Role; + h.get_by_role_and_label(Role::TextInput, "Export Value: ").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_by_role_and_label(Role::TextInput, "Export Value: ").type_text(value); + h.run_steps(2); + } let mut h = harness(); h.state_mut().execute("form.prepare"); h.run_steps(2); @@ -508,13 +516,15 @@ fn button_export_properties_apply_cancel_and_undo() { h.get_by_label("Options").click(); h.run_steps(2); let original = h.state().field_props.as_ref().unwrap().export_value.clone(); - h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + retype(&mut h, "Accepted"); h.get_by_label("Cancel").click(); h.run_steps(2); h.state_mut().open_field_props("agree", 0); h.run_steps(2); assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original); - h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("Options").click(); + h.run_steps(2); + retype(&mut h, "Accepted"); h.get_by_label("OK").click(); h.run_steps(3); { From 0807cb05c3ce280a41decd78f0829b368f7341f7 Mon Sep 17 00:00:00 2001 From: JM Date: Tue, 13 Oct 2026 17:35:45 +0000 Subject: [PATCH 006/270] fix(ui): preserve tool commands across document opening --- crates/ui-egui/src/lib.rs | 61 ++++++-- crates/ui-egui/src/open_worker_tests.rs | 185 ++++++++++++++++++++++++ crates/ui-egui/tests/commands.rs | 17 ++- 3 files changed, 245 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ab87094886..b3ac430ab4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1193,11 +1193,15 @@ impl PdfCraftApp { open_worker::ResultKind::Failed(reason) => { self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]); } - open_worker::ResultKind::Conversion(bytes) => { - if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]); + open_worker::ResultKind::Conversion(bytes) => match self.open_converted(&pending.name, &bytes) { + Some(Ok(())) => { + if let Some(command) = pending.then { + self.execute(command); + } } - } + Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), + None => {} + }, open_worker::ResultKind::Prepared { bytes, result } => match *result { Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) { Ok(id) => { @@ -1727,6 +1731,13 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) { + self.open_path_attempt(path); + } + + /// Whether this attempt opened a document, created a password prompt, or queued an open. + /// Failure can leave an older password prompt intact; it does not belong to this attempt. + #[cfg(not(target_arch = "wasm32"))] + fn open_path_attempt(&mut self, path: &str) -> bool { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); // Taken before reading: a build that rewrites the file meanwhile still counts as a change. let stamp = file_watch::Stamp::at(path); @@ -1734,20 +1745,26 @@ impl PdfCraftApp { // deterministic inline startup and test flows. Once live, reading and preparation use // the bounded worker and commit on the UI thread. if self.ctx.is_none() || self.run_inline { - match std::fs::read(path) { + return match std::fs::read(path) { Ok(bytes) => { let before = self.views.len(); if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); - } else if self.views.len() > before - && let Some(doc) = self.views.last().map(|view| view.id) - { - self.watch.known_as(doc, stamp); + false + } else { + if self.views.len() > before + && let Some(doc) = self.views.last().map(|view| view.id) + { + self.watch.known_as(doc, stamp); + } + true } } - Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]), - } - return; + Err(error) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]); + false + } + }; } self.startup_superseded = true; if let Err(error) = self.schedule_open(OpenRequest { @@ -1762,6 +1779,9 @@ impl PdfCraftApp { then: None, }) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + false + } else { + true } } @@ -2064,12 +2084,21 @@ impl PdfCraftApp { self.ask_one(pickers::Ask::File(dialog), None, move |app, path| { let path = path.to_string_lossy().into_owned(); let before = app.views.len(); - app.open_path(&path); - if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { - // Encrypted: the tool runs once the password is in. - prompt.then = Some(command); + let pending_before = app.pending_opens.len(); + if !app.open_path_attempt(&path) { + return; + } + if app.ctx.is_some() && !app.run_inline { + // UI-thread completion cannot run inside open_path: only its newly queued + // request owns this continuation, including through password retries. + if let Some(pending) = app.pending_opens.get_mut(pending_before) { + pending.then = Some(command); + } } else if app.views.len() > before && app.active.is_some() { app.execute(command); + } else if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { + // Encrypted: the tool runs once the password is in. + prompt.then = Some(command); } }); } diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs index 0e5775feb1..f71c9fbebe 100644 --- a/crates/ui-egui/src/open_worker_tests.rs +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -89,6 +89,191 @@ fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() { let _ = std::fs::remove_file(path); } +#[test] +fn inline_failed_tool_open_does_not_steal_an_existing_password_prompt() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "reader", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-inline-owner-{}-{nonce}.pdf", std::process::id())); + assert!(!path.exists(), "the new opening attempt must fail reading"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.open_bytes("locked.pdf", Some(path.to_string_lossy().into_owned()), bytes).unwrap(); + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let cached = h.state().password_prompt.as_ref().unwrap().bytes.clone(); + assert!(h.state().password_prompt.as_ref().unwrap().then.is_none()); + h.state_mut().run_command("export.text"); + h.step(); + assert!(h.state().toast.as_ref().is_some_and(|toast| toast.0.starts_with("Couldn't read"))); + let prompt = h.state().password_prompt.as_ref().unwrap(); + assert!(Arc::ptr_eq(&prompt.bytes, &cached), "the older encrypted bytes remain available"); + assert!(prompt.then.is_none(), "a failed attempt must not attach its tool to the older prompt"); + h.state_mut().submit_password(Some("wrong".into())); + assert!(h.state().password_prompt.as_ref().unwrap().then.is_none()); + h.state_mut().submit_password(Some("reader".into())); + assert_eq!(h.state().views.len(), 1, "the older document can still be unlocked"); + assert!(h.state().password_prompt.is_none()); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "the failed attempt's tool never runs"); + + // Accepting the current attempt must still run ordinary/conversion tools and retain + // newly created password continuations through retries or discard them on cancellation. + for case in ["plain", "converted", "password", "password-cancel"] { + let path = path.with_extension(if case == "converted" { "txt" } else { "pdf" }); + let bytes = match case { + "plain" => fixture(2), + "converted" => b"Inline text converted to PDF".to_vec(), + _ => cached.as_ref().clone(), + }; + std::fs::write(&path, bytes).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let defaults = h.state().persist(); + h.state_mut().run_command("export.text"); + h.step(); + if case.starts_with("password") { + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + h.state_mut().submit_password(Some("wrong".into())); + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + h.state_mut().submit_password((case == "password").then(|| "reader".into())); + } + assert_eq!(h.state().views.len(), usize::from(case != "password-cancel")); + assert_eq!(matches!(h.state().dialog, Some(crate::Dialog::Export(_))), case != "password-cancel"); + assert!(h.state().password_prompt.is_none()); + assert_eq!(h.state().view_defaults.layout, PdfCraftApp::new().view_defaults.layout); + assert_eq!(h.state().view_defaults.continuous, PdfCraftApp::new().view_defaults.continuous); + // Persistence includes recent files; compare only the saved display defaults. + let before: Value = serde_json::from_str(&defaults).unwrap(); + let after: Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(before["default_layout"], after["default_layout"]); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn tool_open_continuation_follows_its_worker_and_password_retries() { + for case in ["plain", "converted", "invalid-image", "password", "password-cancel", "wrong-password-cancel", "read-failure", "invalid-pdf"] { + let extension = match case { + "converted" => "txt", + "invalid-image" => "png", + _ => "pdf", + }; + let path = std::env::temp_dir().join(format!("pdfcraft-tool-worker-{}-{case}.{extension}", std::process::id())); + let encrypted = case.contains("password"); + if case != "read-failure" { + let bytes = if encrypted { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "reader", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() + } else if case == "converted" { + b"A text file opened as a PDF".to_vec() + } else if case == "invalid-image" { + b"\x89PNG\r\n\x1a\n".to_vec() + } else if case == "invalid-pdf" { + b"not a PDF".to_vec() + } else { + fixture(2) + }; + std::fs::write(&path, bytes).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let (gate, started, release, finished) = open_worker::prepare_gate(); + h.state_mut().open_prepare_gate = Some(gate); + h.state_mut().run_command("export.text"); + h.step(); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(h.state().pending_opens.len(), 1); + assert_eq!(h.state().pending_opens[0].then, Some("export.text"), "{case}: command belongs to the pending request"); + + // A different document becoming active must neither run nor inherit the tool. + h.state_mut().open_bytes("unrelated.pdf", None, fixture(1)).unwrap(); + let unrelated = h.state().views[0].id; + h.step(); + assert!(h.state().dialog.is_none()); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + + if encrypted { + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + assert!(h.state().dialog.is_none()); + if case != "password-cancel" { + h.state_mut().submit_password(Some("wrong".into())); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + let prompt = h.state().password_prompt.as_ref().unwrap(); + assert!(prompt.error.is_some()); + assert_eq!(prompt.then, Some("export.text")); + assert!(h.state().dialog.is_none()); + } + if case == "password" { + h.state_mut().submit_password(Some("reader".into())); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + } else { + h.state_mut().submit_password(None); + } + } + if matches!(case, "plain" | "converted" | "password") { + assert_eq!(h.state().views.len(), 2); + let app = h.state(); + let opened = &app.views[app.active.unwrap()]; + assert_ne!(opened.id, unrelated); + assert_eq!(app.session.get(opened.id).unwrap().path.as_deref(), if case == "converted" { None } else { path.to_str() }); + assert!(matches!(app.dialog, Some(crate::Dialog::Export(_)))); + // Closing the dialog and processing more frames must not execute it again. + h.state_mut().dialog = None; + } else { + assert_eq!(h.state().views.len(), 1); + assert!(h.state().dialog.is_none()); + } + assert!(h.state().pending_opens.is_empty()); + assert!(h.state().password_prompt.is_none()); + h.state_mut().open_bytes("later.pdf", None, fixture(1)).unwrap(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "{case}: continuation cannot run on another open"); + let _ = std::fs::remove_file(path); + } +} + #[test] fn launch_layout_waits_for_cli_opens_and_preserves_restored_tabs() { use crate::canvas::PageLayout; diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 47bbc21a8f..734f35099b 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -44,6 +44,15 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +fn wait_for(h: &mut Harness<'static, PdfCraftApp>, description: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !ready(h.state()) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {description}"); + h.step(); + std::thread::yield_now(); + } +} + /// Commands that open a native file picker can't run headless. /// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.) const PICKERS: &[&str] = &[ @@ -184,7 +193,7 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() { h.run_steps(2); h.state_mut().run_command("export.text"); assert!(h.state().toast.as_ref().is_none_or(|t| t.0 != "Open a document first")); - h.run_steps(3); + wait_for(&mut h, "picked document and export dialog", |app| app.active.is_some() && matches!(app.dialog, Some(Dialog::Export(_)))); assert!(h.state().active.is_some(), "the picked file is open"); assert!(matches!(h.state().dialog, Some(Dialog::Export(_))), "and the tool runs on it: {:?}", h.state().dialog); // Cancelled: nothing opens and nothing runs. @@ -287,7 +296,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { h.get_by_label("Open Recent ⏵").hover(); h.run_steps(3); h.get_by_label_contains("recent.pdf").click(); - h.run_steps(4); + wait_for(&mut h, "recent document", |app| { + app.views.len() == 2 + && app.active.and_then(|index| app.views.get(index)).and_then(|view| app.session.get(view.id)).and_then(|doc| doc.path.as_deref()) + == Some(path.as_str()) + }); let app = h.state(); assert_eq!(app.views.len(), 2, "the recent file opened in a new tab"); let active = app.active.unwrap(); From e2be8db7fbde73c0d10ba896b5dc19561f659bb8 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:33:19 +0200 Subject: [PATCH 007/270] test(ui): stabilize Reduce metadata and isolate output directories --- crates/ui-egui/tests/create.rs | 35 ++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index bfbba2e131..4537ab1fa6 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -69,12 +69,37 @@ fn opening_images_and_text_converts_them_to_new_pdfs() { assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err()); } +// Use a directory claimed exclusively by this run, including after process-ID reuse. +struct ReduceDir(std::path::PathBuf); +impl ReduceDir { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + for _ in 0..128 { + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("pdfcraft-reduce-{}-{n}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("creating Reduce test directory: {error}"), + } + } + panic!("no unused Reduce test directory after 128 attempts"); + } +} +impl Drop for ReduceDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// Open `name` and run Reduce File Size on it, saving to `out` without a dialog. fn reduce(name: &'static str, bytes: Vec, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> { let out2 = out.to_path_buf(); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + // Keep creation and optimization metadata identical across wall-clock seconds. + app.session = std::mem::take(&mut app.session).with_clock(|| 1_700_000_000); app.open_bytes(name, None, bytes).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app @@ -105,9 +130,8 @@ fn reduce_file_size_writes_a_compact_copy() { enc.set_depth(png::BitDepth::Eight); enc.write_header().unwrap().write_image_data(&px).unwrap(); } - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); let h = reduce("photo.png", photo, &out); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-")); @@ -119,9 +143,8 @@ fn reduce_file_size_writes_a_compact_copy() { #[test] fn reduce_file_size_saves_nothing_when_the_file_is_already_small() { use egui_kittest::kittest::Queryable; - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-small-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); // A new text PDF is already compact: a copy would be no smaller (#490). let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out); assert!(!out.exists(), "no copy was written"); From 4e952e62e23e0381c68c2b0f616f4698d5f0f646 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:40:22 +0200 Subject: [PATCH 008/270] chore: reserve Pages sidebar select-all fix (#2052) From 971f6ea2b458a4627aab8b4dcf45ba42f00ef18a Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:14:35 +0200 Subject: [PATCH 009/270] Fix keyboard-owned Pages sidebar select all (#2052) --- ROADMAP.md | 5 +++- crates/ui-egui/src/canvas.rs | 15 ++++++++--- crates/ui-egui/src/lib.rs | 8 ++++-- crates/ui-egui/src/panels.rs | 15 ++++++++--- crates/ui-egui/tests/view.rs | 52 ++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 ++-- parity/acrobat-features.toml | 4 +-- 7 files changed, 89 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b4ab4ea8a6..593387f85a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; 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 (M4/#2052 candidate: keyboard-owned Pages sidebar select-all; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -149,6 +149,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-14 (M4/#2052 candidate):** Ctrl/Cmd+A selects all thumbnails while the Pages sidebar owns the keyboard, preserves the current page, and keeps viewer/text-field shortcuts and document selection separate. Escape and existing permission checks are retained. Focus regression and scoped UI validation pass; estimates unchanged. + - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. @@ -427,6 +429,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | M4/#2052 candidate: Pages sidebar select-all, focus ownership, Escape and document isolation validated; estimates unchanged. | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | | 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..1a7bc20a27 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -433,6 +433,8 @@ pub struct DocView { pub org_drag: Option>, /// Pages panel: the pages being dragged to a new place. pub panel_drag: Option>, + /// Transient keyboard owner for this document's Pages sidebar. + pub(crate) pages_focus_id: Option, /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. pub marquee: Option<(usize, Pos2)>, pub marquee_done: Option, @@ -672,6 +674,7 @@ impl DocView { sign: Default::default(), org_drag: None, panel_drag: None, + pages_focus_id: None, marquee: None, marquee_done: None, fill_text: None, @@ -1137,10 +1140,7 @@ impl DocView { /// Select all: every page in Organize, or every word on the current page. pub fn select_all(&mut self) -> bool { if self.organize { - self.selected = (0..self.page_count).collect(); - // Keep the current page as the anchor for the next Shift-click. - self.select_anchor = (self.page_count > 0).then_some(self.current); - return !self.selected.is_empty(); + return self.select_all_pages(); } let page = self.current; let Some(t) = self.texts.get(&page) else { return false }; @@ -1151,6 +1151,13 @@ impl DocView { true } + /// Select every page without navigating; shared by Organize and the Pages sidebar. + pub fn select_all_pages(&mut self) -> bool { + self.selected = (0..self.page_count).collect(); + self.select_anchor = (self.page_count > 0).then_some(self.current); + !self.selected.is_empty() + } + /// Rotate the view 90° clockwise or counter-clockwise (View ▸ Rotate View, ⇧⌘+ / ⇧⌘−). pub fn rotate_view(&mut self, clockwise: bool) { self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6a00..cad36ad3e1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2650,12 +2650,16 @@ impl PdfCraftApp { if let Some(i) = self.active { // Select all belongs to the document or page grid, unless a text field or // overlay owns the keyboard. Other canvas shortcuts keep their own handling. + let pages_owns_keyboard = + self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id))); if self.dialog.is_none() && !self.palette_open - && !ctx.egui_wants_keyboard_input() + && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) { - if self.is_preparing() { + if pages_owns_keyboard { + self.views[i].select_all_pages(); + } else if self.is_preparing() { if let Some(doc) = self.session.get(self.views[i].id) { prepare::select_all(&mut self.views[i], &doc.form); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..01f5b74fac 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1003,11 +1003,17 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte /// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging /// moves the selection, or the page grabbed, to the gap it is dropped on. fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, editable: bool, nav: &mut Option