From b680ded337354487b26ff56c27b0986e21e0dfcb Mon Sep 17 00:00:00 2001 From: JM Date: Tue, 13 Oct 2026 15:38:04 +0000 Subject: [PATCH 1/4] 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 1d5ce9d7a92731af89f183c7530484b3b625bd52 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sat, 31 Oct 2026 22:26:16 +0100 Subject: [PATCH 2/4] Update shadow regression wheel events and retain upstream parity notes --- crates/ui-egui/tests/page_shadows.rs | 1 + parity/acrobat-features.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/page_shadows.rs b/crates/ui-egui/tests/page_shadows.rs index 1062605832..a83fa6ce05 100644 --- a/crates/ui-egui/tests/page_shadows.rs +++ b/crates/ui-egui/tests/page_shadows.rs @@ -88,6 +88,7 @@ fn scroll_down(h: &mut Harness<'static, PdfCraftApp>, points: f32) { delta: egui::vec2(0.0, -points), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(12); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 98bfdccc38..670b21da01 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9928,8 +9928,8 @@ milestone = "M14" status = "partial" commands = [] tools = [] -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/it/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/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" +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/it/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] +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, unless Always use my default zoom and page layout is on. 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 9a655ddb2dc8965bbfb1ad98776a4ebdff6b04d8 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 31 Oct 2026 20:33:48 +0530 Subject: [PATCH 3/4] test(optimize): accept Flate or CCITT compression in monochrome scan test --- crates/optimize/tests/optimize.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2e3ea2d42f..683cbd0684 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1775,7 +1775,8 @@ fn monochrome_images_keep_what_they_should() { optimize(&mut doc, &Settings::default()).unwrap(); assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey"); assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default"); - assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller"); + assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate"); + assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample"); assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image"); let (mut doc, [_, wrapped, scan, _, _]) = build(); From 9af62ceb386d236fc9eec1e33c5606258eadb7b8 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sat, 31 Oct 2026 23:07:05 +0100 Subject: [PATCH 4/4] Isolate UI frame-budget measurements under nextest --- .config/nextest.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 2a4cd2cb93..11ec94cc1a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +1,10 @@ # Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI # installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). nextest-version = "0.9.150" + +# These measurements require a quiet runner. Their in-process SERIAL mutex only +# serializes cargo test; nextest gives each test its own process. Reserve all slots +# so other tests cannot charge their CPU/render-worker time to a frame budget. +[[profile.default.overrides]] +filter = 'package(pdfcraft-ui-egui) & binary(perf)' +threads-required = "num-test-threads"