From a1acf5283c999c34c5a6d8861ee1f6df3887a344 Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:58:41 +0000 Subject: [PATCH] Print: remember selected paper size across restarts (#2156) --- ROADMAP.md | 3 + crates/print/README.md | 6 ++ crates/ui-egui/src/lib.rs | 16 ++++- crates/ui-egui/tests/print.rs | 131 +++++++++++++++++++++++++++++++++- docs/roadmap.md | 3 +- parity/acrobat-features.toml | 4 +- 6 files changed, 156 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b4ab4ea8a..72ce62f9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged) > **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-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) @@ -149,6 +150,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-14 (M10 #2156 candidate):** Print paper selection survives Cancel and settings restoration; canonical names avoid preset-index dependence. Other print preferences and 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. diff --git a/crates/print/README.md b/crates/print/README.md index 6a489977d..d938e81c9 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -32,6 +32,12 @@ Each source page becomes a Form XObject (its content wrapped in q/Q, plus the pr annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). +The GUI Print dialog remembers its last selected paper size across application sessions, +including when Print is cancelled. The preference stores a canonical name such as `A4`; +unrecognized saved values retain the current selection (US Letter on a fresh app). +This change does not alter persistence of other print preferences. +Headless `doc_print` defaults are unchanged. + `spool` delegates discovery, driver options, preferences and submission to [`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). PdfCraft retains page selection, imposition, annotations and print-permission checks. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6a0..7e67b9cc7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2096,7 +2096,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, @@ -2155,8 +2155,11 @@ impl PdfCraftApp { "combine_zoom": self.combine_zoom, "comments_panel_closed": self.comments_panel_closed, "right_panel_closed": self.right_panel_closed, - }) - .to_string() + }); + // Add separately to avoid growing the large settings macro. The literal above is an object. + // Canonical names survive changes to the order of PAPERS and do not depend on UI language. + settings["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + settings.to_string() } /// Restore state written by `persist`. Unknown or malformed data is ignored. @@ -2213,6 +2216,13 @@ impl PdfCraftApp { if let Ok(units) = serde_json::from_value::(v["units"].clone()) { self.units = units; } + if let Some(paper) = v + .get("print_paper") + .and_then(serde_json::Value::as_str) + .and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name)) + { + self.print_draft.paper = paper; + } if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; } diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs index 739c04ee9..0999023bf 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/print.rs @@ -5,9 +5,16 @@ use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + harness_with_settings(None) +} + +fn harness_with_settings(settings: Option) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + if let Some(json) = &settings { + app.restore(json); + } app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app }); @@ -373,3 +380,125 @@ fn a_printer_without_two_sided_printing_points_to_its_properties() { h.run_steps(2); h.get_by_label_contains(note); } + +#[test] +fn paper_choice_survives_cancel_and_settings_restore() { + use pdfcraft_engine::print::{Orientation, PAPERS}; + let a4 = PAPERS.iter().position(|(name, _)| *name == "A4").unwrap(); + let letter = PAPERS.iter().position(|(name, _)| *name == "US Letter").unwrap(); + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.get_by_value("US Letter").click(); + h.run_steps(2); + h.get_by_label("A4").click(); + h.run_steps(2); + assert_eq!(h.state().print_draft.paper, a4); + // Only paper should be added to persistence; these choices still belong to this session. + let draft = &mut h.state_mut().print_draft; + draft.copies = 5; + draft.which = pdfcraft_ui_egui::PrintWhich::Range; + draft.range = "1".into(); + draft.orientation = Orientation::Landscape; + draft.grayscale = true; + let before_cancel = draft.clone(); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert_eq!(h.state().print_draft, before_cancel, "cancel keeps the existing draft behavior"); + let saved = h.state().persist(); + let json: serde_json::Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(json["print_paper"], "A4", "store the canonical name, not its position"); + let mut app = PdfCraftApp::new(); + app.restore(&saved); + let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, ..Default::default() }; + assert_eq!(app.print_draft, expected, "no other print preference becomes persistent"); + let mut restarted = harness_with_settings(Some(saved)); + assert!(restarted.state_mut().execute("print.dialog")); + restarted.run_steps(3); + restarted.get_by_value("A4"); + assert_eq!(restarted.state().print_draft.paper, a4); + restarted.get_by_value("A4").click(); + restarted.run_steps(2); + restarted.get_by_label("US Letter").click(); + restarted.run_steps(2); + restarted.get_by_label("Cancel").click(); + restarted.run_steps(2); + let saved = restarted.state().persist(); + assert_eq!(serde_json::from_str::(&saved).unwrap()["print_paper"], "US Letter"); + let mut again = harness_with_settings(Some(saved)); + assert!(again.state_mut().execute("print.dialog")); + again.run_steps(3); + again.get_by_value("US Letter"); + assert_eq!(again.state().print_draft.paper, letter); +} + +#[test] +fn paper_restore_accepts_canonical_names_and_ignores_invalid_settings() { + use pdfcraft_engine::print::PAPERS; + for (index, (name, _)) in PAPERS.iter().enumerate() { + let mut app = PdfCraftApp::new(); + app.restore(&serde_json::json!({"print_paper": name}).to_string()); + assert_eq!(app.print_draft.paper, index); + assert_eq!(serde_json::from_str::(&app.persist()).unwrap()["print_paper"], *name); + } + for invalid in [ + "not JSON", + "{}", + r#"{"print_paper":null}"#, + r#"{"print_paper":true}"#, + r#"{"print_paper":[]}"#, + r#"{"print_paper":{}}"#, + r#"{"print_paper":"unknown"}"#, + r#"{"print_paper":"a4"}"#, + r#"{"print_paper":4}"#, + r#"{"print_paper":-1}"#, + r#"{"print_paper":4294967296}"#, + r#"{"print_paper":18446744073709551615}"#, + r#"{"print_paper":1e100}"#, + ] { + let mut app = PdfCraftApp::new(); + let factory = app.print_draft.paper; + app.restore(invalid); + assert_eq!(app.print_draft.paper, factory, "{invalid}"); + app.restore(r#"{"print_paper":"A4"}"#); + let selected = app.print_draft.paper; + app.restore(invalid); + assert_eq!(app.print_draft.paper, selected, "invalid input must not replace a valid choice: {invalid}"); + } + let mut app = PdfCraftApp::new(); + app.print_draft.paper = usize::MAX; + assert_eq!(serde_json::from_str::(&app.persist()).unwrap()["print_paper"], serde_json::Value::Null); +} + +#[test] +fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { + use pdfcraft_engine::print::{self, PAPERS}; + let a4 = PAPERS.iter().find(|(name, _)| *name == "A4").unwrap().1; + let mut h = harness_with_settings(Some(r#"{"print_paper":"A4"}"#.into())); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.get_by_value("A4"); + h.get_by_label("Sheet 1 of 1"); + let id = h.state().views[h.state().active.unwrap()].id; + let doc = h.state().session.get(id).unwrap(); + let sizes: Vec<_> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + let labels: Vec<_> = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let settings = h.state().print_draft.settings(sizes.len(), &labels).unwrap(); + assert_eq!(settings.paper, a4); + let sheets = print::layout(&sizes, &settings).unwrap(); + assert_eq!(sheets.len(), 1); + assert_eq!(sheets[0].size, a4, "preview uses the restored paper"); + let out = std::env::temp_dir().join(format!("pdfcraft-restored-paper-{}.pdf", std::process::id())); + h.state_mut().print_draft.printer = None; + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let printed = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(&out).unwrap())).unwrap(); + let pages = pdfcraft_model::pages(&printed); + assert_eq!(pages.len(), sheets.len()); + let size = pages[0].display_size(&printed); + assert!((size.0 - a4.0).abs() < 0.01 && (size.1 - a4.1).abs() < 0.01, "saved sheet: {size:?}"); + std::fs::remove_file(out).unwrap(); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index fabb5e603..ec21c9c23 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged) > **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-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) @@ -110,7 +111,7 @@ total matches the parity estimate. | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 359a71e9a..77015d26e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7912,8 +7912,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"] -notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog" +evidence = ["crates/ui-egui/tests/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"] +notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences" [[feature]] id = "print.printer-selection"