diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 47db5c42b..547813699 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 = "be7e716d9129584028570281aa7ee93a9d0408e267100eedfe5fed1b991856d9" +sha256 = "2379b0e569145ec990aa8178d1f660d03f7a9a0912e9872e4f3ee37cdbb4e512" # 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 = "e1aa703d9512937ece3626f5389f0c8441239ba71081a98cfa97cb55bc88b6ed" +sha256 = "cf9bc5c10e692e4a531f2d79a16561718da7aff5a66e507bcf484c9f739eeeff" [[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 = "beeb5d9842b93ff054022109ec7bbb1b918056c918239ac43e09b29e53f9bdb4" +sha256 = "2dd61c7fa8de8bcf70e4a3b86a5d56a4b95c13cf420999d76be1fe189a896262" [[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 = "74fd38000f15c411800f9eb4bab49e0acaf39a1b6f67b237230d55fc914aed4a" +sha256 = "495e3736e44902b6cc17c200e638062ab36713f16ab7733dd8e617ee768ddf71" [[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 = "84a4f748568c3bcb9cb23ccbbe344c7ad28bea47a78c9dfa1d7f3d5874e101cd" +sha256 = "75592f866897ef06101de3c8d1b5e7300c70fdc85f341f25c5df4345172bc5e9" [[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 = "433dde6b9e840272c1e4215712243a288f56617fc7c9e71f40a759699849ef86" +sha256 = "59509552e8662ef4d401e988e74a1d3fb8563dd3988a34afadbff3a869be5978" [[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 = "55c74cc9e2af3174433d7207fa512787bb4b8e01188edd9ab290d2839001d2b2" +sha256 = "946ff2ecd91ad4c4e862a12ceabd99725c7e950509af7c7ed41401dbf3011c91" [[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 = "ee31ca945449a5981bf13d838cb9e111f453af80782fac0d25548fa0350d1b06" +sha256 = "96431c098572a076d5802a26fd1668a70f0a0c8652b6e2d28f9ec715b72f594a" [[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 = "527b12ba4605671e390e2e2375cae32378de6bf26d05fb171f97c01593c0ab7d" +sha256 = "4f275bca7634cb72576cc2b736c29c95591553478fc949017a07f1c2ef17c76b" [[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 = "feca2c5cdfc814556acf66da18ef293c8201374cfc3835d7a81c1aef8614d30c" +sha256 = "8c948f8e8135a67301c66816a0514268971c1a4634075fc56d1ca102d597992f" [[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 = "89540ed71d1f2b3420333f1e9b6c3561c3a82407f0cd6d0bbe959f2e112a0302" +sha256 = "ee5c0c87ba192c732647e9048d01aa2451eb8a0f8301eea33b87150a7b32e89b" [[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 = "531f802250a87f12c0352ff1f0dbeb8752151b0078ec1937e062cd6a939db902" +sha256 = "204150b3f32d8477e750e6836622b3b6407fcbde6660cce450883fb006ab1545" [[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 = "2605fa94a7cd33be345c8bc3828e16eed25029530211a9ab63e72913d993a254" +sha256 = "ff872d2b6f4ac5de20efc2356579dc88f6d69a044cbd06912f00e1b89244dc2b" [[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 = "783b16274172d4de2a4d7899de8717601ee13aa92b271b99cd29f4c5bf9fd91d" +sha256 = "b0f2ee61bb28aa895c4b963fcc9daeaf5d6d9e705d3e93a6978c56e969b0b89e" [[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 = "4c01c32afa1f96fc9a6b059f920cab3155c1ccb0de2a144654adb6be3c80b5a9" +sha256 = "7b83af3d55edbc228c6e5ae7888ad3aa5ebe9b54b221211f74c3f97987f650d6" [[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 = "aad7fee85b9eec1577733907bb8a7c65ae2443190532fed6d813cd65ff2edda0" +sha256 = "8deab44598436df4b5b8c0fe61063234896b036e630b99c5a0c4e3065376c0b4" [[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 = "cff0485f0ac85783b145e11f454e5a0611bd8a680ebe54e8243fda9c6568b304" +sha256 = "5a39e70248787feb397cb4c9cc7877beb87a784f4b755e4fbc2b413411d33285" [[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 = "47d2ec69b80352769c39a88c775a17ae7e4505325e2ad35ea2a1b7a46292fad9" +sha256 = "5755f7000aee953bb3bae279a57f1346c0c5db43325c03081a7909a055730c02" [[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 = "5b3f045d4ddc84300a9809f2cdf4317168b7db0dda5f483c1dbfb2e8b32b673f" +sha256 = "662b7d2c4c57aa8ccbbf9676326eb0ead9ebb0fb1f75ebced0e5456b7e9798e2" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,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 = "931432d897c060dde5e9758de137fe4d4c2f7a19d244c519df2413b51f17961f" +sha256 = "898ee65f18a4fe527893ecc917362397664e8b4b1c2c0f520158fdf233e40f9a" diff --git a/Cargo.lock b/Cargo.lock index 324d669bb..bc352c022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,6 +1090,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1741,7 +1749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3620,6 +3628,7 @@ dependencies = [ "pdfcraft-xfa", "png 0.18.1", "serde_json", + "sha2", ] [[package]] @@ -3860,6 +3869,7 @@ dependencies = [ name = "pdfcraft-print" version = "0.6.0" dependencies = [ + "craft-print", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", @@ -4900,7 +4910,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5526,7 +5536,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5794,7 +5804,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6486,7 +6496,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ddea910f4..84650ac3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +# Shared system spooler; document composition remains in pdfcraft-print. +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } # internal pdfcraft-geom = { path = "crates/geom", version = "0.6.0" } pdfcraft-filters = { path = "crates/filters", version = "0.6.0" } diff --git a/README.md b/README.md index e6227849c..3947c29ce 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ PdfCraft is young and moving fast. The aim is a workbench where you can view, or - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, and full XFA forms. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. - **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. **Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs, web printing and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. diff --git a/ROADMAP.md b/ROADMAP.md index f61ae5513..05c13b482 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-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Smooth scrolling: system, on or off); earlier: M10, #1028: OCR reads pictures on pages that have text, on request; earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -136,12 +137,14 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged. - **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged. Newest first: the date, what moved, and the new overall estimate. +- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. - **2026-10-11 (M3, smooth scrolling and Windows' Animation effects setting):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has Smooth scrolling: Use system setting (the default), On or Off. With Animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. Smooth scrolling set to On or Off decides the wheel's scrolling and zooming, and scrolling to a place, whatever the system says (menus, sections and the progress card always follow the system); it is saved, set as `smooth_scrolling` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting eases there as before; Off works everywhere. Estimates unchanged. - +- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. - **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. - **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. - **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. diff --git a/apps/pdfcraft/tests/winit_x11_dpi.rs b/apps/pdfcraft/tests/winit_x11_dpi.rs new file mode 100644 index 000000000..fed0cf3e4 --- /dev/null +++ b/apps/pdfcraft/tests/winit_x11_dpi.rs @@ -0,0 +1,14 @@ +#[test] +fn winit_carries_the_x11_server_dpi_fallback() { + // The behavior tests live beside the policy in winit (util::randr::tests). + // This guards PdfCraft's vendored copy, which workspace tests do not test itself. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let randr = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/linux/x11/util/randr.rs")).unwrap(); + assert!(randr.contains("let screen = self.default_root();")); + assert!(randr.contains("default_dpi_factor(\n self.get_xft_dpi(),")); + assert!(randr.contains("screen.width_in_millimeters.into()")); + assert!(randr.contains("calc_dpi_factor(screen_pixels, screen_mm)")); + assert!(randr.contains("server_dpi_wins_over_the_panels_physical_density")); + let desktop = std::fs::read_to_string(root.join("packaging/linux/ai.storyteller.pdfcraft.desktop")).unwrap(); + assert!(!desktop.contains("WINIT_X11_SCALE_FACTOR")); +} diff --git a/contributors/people.toml b/contributors/people.toml index 3d361819d..37a8b146d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -104,6 +104,9 @@ sync_github_name = true # use the name on your public GitHub profile as displa [people.Losminek] sync_github_name = true +[people.earlvanze] +sync_github_name = true + [people.rp6186030] real_name = "Raj Patel" sync_github_name = false diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 57fc46a2e..112c83748 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -48,7 +48,7 @@ pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec { } /// Indirect structure elements in document order, with their page. -fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option)> { +pub(crate) fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option)> { let Some(root) = tree_root(doc) else { return Vec::new() }; let mut out = Vec::new(); let mut seen = HashSet::new(); diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 60b261026..8154cd70b 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -17,10 +17,12 @@ mod content; mod headings; mod report; mod structure; +mod tags; pub use alt::{AltError, Figure, figures, mark_decorative, set_alt}; pub use headings::{Heading, headings}; pub use report::{REPORT_WORDS, report_html}; +pub use tags::{TagError, TagInfo, TagProperty, set_tag_property, tags}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Category { @@ -422,6 +424,8 @@ impl Status { pub struct Finding { /// 0-based page, when the problem is on a page. pub page: Option, + /// Indirect structure element that caused the finding, when one is available. + pub object: Option, pub message: String, /// The English sentence with a hole, such as `{p}` or `{what}`, where each value went. pub pattern: &'static str, @@ -432,13 +436,13 @@ pub struct Finding { impl Finding { /// A finding whose sentence says the same thing however the document is put together. pub fn plain(page: Option, sentence: &'static str) -> Self { - Finding { page, message: sentence.to_owned(), pattern: sentence, values: Vec::new() } + Finding { page, object: None, message: sentence.to_owned(), pattern: sentence, values: Vec::new() } } /// A finding built from a wording and what goes in its holes. The English `message` is filled /// in here, so it and `pattern` can never drift apart. pub fn worded(page: Option, pattern: &'static str, values: Vec<(&'static str, String)>) -> Self { - Finding { page, message: say(pattern, &values), pattern, values } + Finding { page, object: None, message: say(pattern, &values), pattern, values } } /// A finding that says where the problem is and, in `{what}`, what is wrong, each worded @@ -450,7 +454,7 @@ impl Finding { pub fn nested(page: Option, pattern: &'static str, what: &'static str, mut values: Vec<(&'static str, String)>) -> Self { let message = say(&pattern.replace("{what}", what), &values); values.push(("{what}", what.to_owned())); - Finding { page, message, pattern, values } + Finding { page, object: None, message, pattern, values } } /// This finding as one sentence, said in the language `words` translates into. diff --git a/crates/a11y/src/structure.rs b/crates/a11y/src/structure.rs index 2a481148f..b5d5c1f9a 100644 --- a/crates/a11y/src/structure.rs +++ b/crates/a11y/src/structure.rs @@ -154,6 +154,8 @@ fn kind_of(ty: &[u8]) -> Kind { /// is filled in as the walk goes on. #[derive(Debug)] pub(crate) struct Frame { + /// Indirect object holding this tag, if it is not an inline dictionary. + object: Option, /// The standard type after the role map, or the type as written when it maps to nothing. ty: Vec, kind: Kind, @@ -314,7 +316,7 @@ impl Walker<'_> { } Object::Int(_) => self.mark_content(), Object::Ref(r) => self.reference(*r, depth, page, v), - Object::Dict(_) => self.dict(loc, depth, page, v), + Object::Dict(_) => self.dict(loc, depth, page, None, v), _ => {} } } @@ -326,13 +328,13 @@ impl Walker<'_> { } let target = self.doc.get(r); if target.as_dict().is_some() { - self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, v); + self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, Some(r), v); } } /// A dictionary. Marked content and object references only mark the open element; a /// dictionary with /S is an element, and its /K kids are read inside it. - fn dict(&mut self, loc: &Loc, depth: usize, page: Option, v: &mut V) { + fn dict(&mut self, loc: &Loc, depth: usize, page: Option, object: Option, v: &mut V) { let Some(d) = loc.value().and_then(|o| o.as_dict()) else { return }; match d.name(b"Type") { Some(b"MCR") => return self.mark_content(), @@ -356,6 +358,7 @@ impl Walker<'_> { let kind = kind_of(&ty); let attrs = if matches!(kind, Kind::Table | Kind::Td | Kind::Th) { self.attr_dicts(d) } else { Vec::new() }; let frame = Frame { + object, ty, kind, index: self.opened, @@ -463,13 +466,15 @@ fn row_owner(path: &[Frame]) -> Option { fn worded(e: &Frame, what: &'static str, extra: Vec<(&'static str, String)>) -> Finding { let mut values = extra; values.push(("{ty}", String::from_utf8_lossy(&e.ty).into_owned())); - match e.page { + let mut finding = match e.page { Some(p) => { values.push(("{p}", p.saturating_add(1).to_string())); Finding::nested(e.page, "{ty} element on page {p}: {what}", what, values) } None => Finding::nested(None, "{ty} element: {what}", what, values), - } + }; + finding.object = e.object; + finding } /// A finding whose `{what}` wording has no holes of its own. diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs new file mode 100644 index 000000000..4ba25fd79 --- /dev/null +++ b/crates/a11y/src/tags.rs @@ -0,0 +1,111 @@ +//! Properties of reachable indirect structure elements. + +use pdfcraft_cos::{CosError, Document, ObjRef, Object, PdfString}; + +use crate::alt; + +/// A tag that can be addressed by an edit. Inline dictionaries have no stable object reference. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagInfo { + pub object: ObjRef, + pub kind: String, + pub title: Option, + pub actual_text: Option, + pub alt: Option, + pub language: Option, + /// Zero-based page when `/Pg` is known. + pub page: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TagProperty { + Type, + Title, + ActualText, + AlternateText, + Language, +} + +impl TagProperty { + pub fn from_id(id: &str) -> Option { + match id { + "type" => Some(Self::Type), + "title" => Some(Self::Title), + "actual_text" => Some(Self::ActualText), + "alternate_text" => Some(Self::AlternateText), + "language" => Some(Self::Language), + _ => None, + } + } + + fn key(self) -> &'static [u8] { + match self { + Self::Type => b"S", + Self::Title => b"T", + Self::ActualText => b"ActualText", + Self::AlternateText => b"Alt", + Self::Language => b"Lang", + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum TagError { + #[error("object {0} is not a reachable indirect structure tag")] + NotATag(ObjRef), + #[error("a tag type must be a nonempty PDF name using letters, digits, colon, period, underscore or hyphen")] + InvalidType, + #[error("tag property text is too long (maximum 65536 bytes)")] + TooLong, + #[error(transparent)] + Cos(#[from] CosError), +} + +fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option { + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text)) +} + +/// List reachable, indirect tags in structure-tree order. +pub fn tags(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + let page_index: std::collections::HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + alt::elements(doc) + .into_iter() + .filter_map(|(object, d, page)| { + Some(TagInfo { + object, + kind: String::from_utf8_lossy(d.name(b"S")?).into_owned(), + title: text(doc, &d, b"T"), + actual_text: text(doc, &d, b"ActualText"), + alt: text(doc, &d, b"Alt"), + language: text(doc, &d, b"Lang"), + page: page.and_then(|p| page_index.get(&p).copied()), + }) + }) + .collect() +} + +/// Change one property of a reachable tag; `None` removes a text property. +pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> { + if !alt::elements(doc).iter().any(|(r, _, _)| *r == object) { + return Err(TagError::NotATag(object)); + } + // ActualText may intentionally contain leading or trailing whitespace; preserve it. + let value = value.filter(|s| !s.is_empty()); + if value.is_some_and(|s| s.len() > 65_536) { + return Err(TagError::TooLong); + } + if property == TagProperty::Type + && !value.is_some_and(|s| s.len() <= 127 && s.as_bytes().iter().all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'.' | b'_' | b'-'))) + { + return Err(TagError::InvalidType); + } + doc.update_dict(object, |d| match value { + Some(s) if property == TagProperty::Type => d.set(property.key().to_vec(), Object::name(s)), + Some(s) => d.set(property.key().to_vec(), Object::String(PdfString::text(s))), + None => { + d.remove(property.key()); + } + })?; + Ok(()) +} diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 9aa3f57ca..3d320e9b6 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -164,6 +164,38 @@ fn structure_doc(objects: &[String]) -> Document { pdf(&objs, "") } +#[test] +fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() { + let mut doc = + structure_doc(&["<< /S /Document /K [4 0 R] >>".into(), "<< /S /Figure /T (Old) /Alt (A chart) >>".into(), "<< /S /Figure >>".into()]); + let tag = pdfcraft_cos::ObjRef::new(4, 0); + let listed = tags(&doc); + assert_eq!(listed.len(), 2); + assert_eq!(listed[1].object, tag); + assert_eq!(listed[1].title.as_deref(), Some("Old")); + for (property, value) in [ + (TagProperty::Type, "P"), + (TagProperty::Title, "Figure title"), + (TagProperty::ActualText, " Revenue increased "), + (TagProperty::AlternateText, "A rising chart"), + (TagProperty::Language, "en-GB"), + ] { + set_tag_property(&mut doc, tag, property, Some(value)).unwrap(); + } + let edited = &tags(&doc)[1]; + assert_eq!(edited.kind, "P"); + assert_eq!(edited.title.as_deref(), Some("Figure title")); + assert_eq!(edited.actual_text.as_deref(), Some(" Revenue increased ")); + assert_eq!(edited.alt.as_deref(), Some("A rising chart")); + assert_eq!(edited.language.as_deref(), Some("en-GB")); + set_tag_property(&mut doc, tag, TagProperty::Title, None).unwrap(); + assert_eq!(tags(&doc)[1].title, None); + assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, None), Err(TagError::InvalidType))); + assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, Some("bad type")), Err(TagError::InvalidType))); + assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(5, 0), TagProperty::Title, Some("No")), Err(TagError::NotATag(_)))); + assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_)))); +} + fn checked(doc: &Document, rules: &[Rule]) -> Report { check(doc, &Options { rules: rules.iter().copied().collect(), pages: None }) } @@ -216,6 +248,16 @@ fn cycles_shared_kids_and_missing_kids_are_read_once() { assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1); } +#[test] +fn structure_findings_identify_indirect_elements_without_guessing_inline_objects() { + let doc = structure_doc(&["<< /S /Document /K [4 0 R << /S /Figure >>] >>".into(), "<< /S /Figure >>".into()]); + let r = checked(&doc, &[Rule::FiguresAltText]); + let found = &r.result(Rule::FiguresAltText).unwrap().findings; + assert_eq!(found.len(), 2); + assert_eq!(found[0].object.map(|r| r.num), Some(4)); + assert_eq!(found[1].object, None); +} + #[test] fn nesting_is_read_to_the_depth_limit_and_no_further() { // Sections, each the kid of the one before, ending in a figure without alternate text. The diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index f9227c07d..70773eccd 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -10,10 +10,12 @@ workspace = true [dependencies] pdfcraft-engine = { workspace = true } +pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-organize = { workspace = true } pdfcraft-platform = { workspace = true } serde_json = { workspace = true } +sha2 = "0.11" png = "0.18" base64 = { version = "0.22", optional = true } @@ -25,7 +27,6 @@ mcp = ["dep:base64"] [dev-dependencies] pdfcraft-fonts = { workspace = true } -pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-xfa = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/crates/automation/README.md b/crates/automation/README.md index 1c0b121f2..29f130ee3 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -85,6 +85,15 @@ moves 1–1000 references atomically in displayed points (right/down), preservin and fonts. A refusal changes nothing; success is one undo step. See [the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples. +## Accessibility review record + +Open the original and edited PDFs as separate documents, then call +`accessibility_review_record {before, after, path}`. The tool writes a JSON record atomically within +`--root`: SHA-256 hashes of both working PDFs, rule statuses and finding counts before and after, +changed rules, and rules still failed or requiring manual review. `all: true` also includes the +colour-contrast rule. It does not change either PDF or certify accessibility. Keep the original open +while making edits to the second document so the comparison has a stable baseline. + ## Common command tools Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 3e7a046a8..fb2ab7928 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -1,7 +1,9 @@ //! Accessibility tools: the full check, the accessibility report, and the automatic fixes. +use pdfcraft_engine::DocId; use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; @@ -31,7 +33,18 @@ pub(crate) fn report_json(r: &Report) -> Value { .results .iter() .map(|x| { - let findings: Vec = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect(); + let findings: Vec = x + .findings + .iter() + .map(|f| { + json!({ + "page": f.page.map(|p| p + 1), + "object": f.object.map(|r| r.num), + "object_generation": f.object.map(|r| r.generation), + "message": f.message, + }) + }) + .collect(); json!({ "rule": x.rule.id(), "name": x.rule.name(), "category": category_id(x.rule.category()), "status": status_id(x.status), "findings": findings }) }) .collect(); @@ -93,6 +106,78 @@ impl Automation { Ok(out) } + pub(crate) fn a11y_review_record(&self, a: &Args) -> Result { + let id = |key| -> Result { + let n = u64::try_from(a.int(key)?).map_err(|_| ToolError::InvalidArgs(format!("{key} must be positive")))?; + if n == 0 { + return Err(ToolError::InvalidArgs(format!("{key} must be positive"))); + } + Ok(DocId(n)) + }; + let before_id = id("before")?; + let after_id = id("after")?; + if before_id == after_id { + return Err(ToolError::InvalidArgs("before and after must be separate open documents".into())); + } + let before = self.session.get(before_id).ok_or_else(|| failed(format!("no open document with id {}", before_id.0)))?; + let after = self.session.get(after_id).ok_or_else(|| failed(format!("no open document with id {}", after_id.0)))?; + let mut options = Options::default(); + if a.opt_bool("all")?.unwrap_or(false) { + options.rules = Rule::ALL.into_iter().collect(); + } + let before_report = before.accessibility_check(&options).ok_or_else(|| failed("the original document can't be read for checking"))?; + let after_report = after.accessibility_check(&options).ok_or_else(|| failed("the edited document can't be read for checking"))?; + if before_report.results.len() != after_report.results.len() { + return Err(failed("the two accessibility checks returned different rule sets")); + } + let mut rules = Vec::new(); + let mut changed = Vec::new(); + let mut review_required = Vec::new(); + for (prior, current) in before_report.results.iter().zip(&after_report.results) { + if prior.rule != current.rule { + return Err(failed("the two accessibility checks returned different rule sets")); + } + let rule = prior.rule.id(); + let before_status = status_id(prior.status); + let after_status = status_id(current.status); + let before_findings = prior.findings.len(); + let after_findings = current.findings.len(); + if before_status != after_status || before_findings != after_findings { + changed.push(rule); + } + if matches!(current.status, Status::Failed | Status::Manual) { + review_required.push(rule); + } + rules.push(json!({ + "rule": rule, + "before": { "status": before_status, "findings": before_findings }, + "after": { "status": after_status, "findings": after_findings }, + })); + } + let (y, m, d) = self.session.today(); + let hash = |bytes: &[u8]| Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect::(); + let record = json!({ + "schema": 1, + "date": format!("{y}-{m:02}-{d:02}"), + "before": { "name": before.name, "sha256": hash(before.bytes.as_slice()), "summary": { + "passed": before_report.count(Status::Passed), "failed": before_report.count(Status::Failed), + "manual": before_report.count(Status::Manual), "skipped": before_report.count(Status::Skipped), + } }, + "after": { "name": after.name, "sha256": hash(after.bytes.as_slice()), "summary": { + "passed": after_report.count(Status::Passed), "failed": after_report.count(Status::Failed), + "manual": after_report.count(Status::Manual), "skipped": after_report.count(Status::Skipped), + } }, + "rules": rules, + "changed": changed, + "review_required": review_required, + "note": "Automated checks cover only part of accessibility. Review remaining findings manually and validate the final PDF independently.", + }); + let path = self.resolve(a.str("path")?, true)?; + let bytes = serde_json::to_vec_pretty(&record).map_err(failed)?; + write_atomic(&path, &bytes)?; + Ok(json!({ "path": path.to_string_lossy(), "record": record })) + } + pub(crate) fn a11y_fix(&mut self, a: &Args) -> Result { let id = a.str("rule")?; let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?; @@ -135,6 +220,40 @@ impl Automation { self.session.apply(id, edit).map_err(failed)?; self.accessibility_figures(a) } + + pub(crate) fn accessibility_tags(&self, a: &Args) -> Result { + let tags: Vec = self + .doc(a)? + .tags() + .into_iter() + .map(|t| { + json!({ + "object": t.object.num, "generation": t.object.generation, "type": t.kind, + "title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt, + "language": t.language, "page": t.page.map(|p| p + 1), + }) + }) + .collect(); + Ok(json!({ "count": tags.len(), "tags": tags })) + } + + pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + if object == 0 { + return Err(ToolError::InvalidArgs("object must be a positive tag object number".into())); + } + let generation = u16::try_from(a.int("generation")?) + .map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?; + let property_id = a.str("property")?; + let property = pdfcraft_engine::a11y::TagProperty::from_id(property_id) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown tag property {property_id:?}")))?; + let value = a.opt_str("value")?.map(str::to_owned); + let id = self.doc(a)?.id; + self.session + .apply(id, pdfcraft_engine::Edit::SetTagProperty { object: pdfcraft_cos::ObjRef::new(object, generation), property, value }) + .map_err(failed)?; + self.accessibility_tags(a) + } } impl Automation { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5b7d7fe3e..327a740d4 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -640,9 +640,12 @@ impl Automation { "ocr_status" => self.ocr_status()?, "ocr_recognize_files" => self.ocr_recognize_files(&a)?, "accessibility_report" => self.a11y_report(&a)?, + "accessibility_review_record" => self.a11y_review_record(&a)?, "accessibility_fix" => self.a11y_fix(&a)?, "accessibility_figures" => self.accessibility_figures(&a)?, "accessibility_set_alt" => self.accessibility_set_alt(&a)?, + "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, "sign_keychain_ids" => { diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 15b56eae8..b9529dcfa 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -141,12 +141,13 @@ impl Automation { printer: if printer == "default" { None } else { Some(printer.to_string()) }, copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, collate: a.opt_bool("collate")?.unwrap_or(true), - duplex: match a.opt_str("duplex")?.unwrap_or("off") { + duplex: Some(match a.opt_str("duplex")?.unwrap_or("off") { "off" => spool::Duplex::Off, "long-edge" => spool::Duplex::LongEdge, "short-edge" => spool::Duplex::ShortEdge, d => return Err(bad(format!("unknown duplex {d:?}"))), - }, + }), + scaling: spool::Scaling::Exact, grayscale: a.opt_bool("grayscale")?.unwrap_or(false), title: name, options: match a.get("options") { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0534759e2..76f601a7b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -866,6 +866,17 @@ pub fn tools() -> Vec { }), &["doc", "path"], )), + t("accessibility_review_record", "Accessibility review record", "Compare the same accessibility rules on two open versions of a PDF and write a JSON record to path. Includes document SHA-256 hashes, before/after rule statuses and finding counts, changed rules, and rules still needing review. Does not change either PDF or claim accessibility compliance. Open the original and edited copies as separate documents.") + .destructive() + .with(schema( + json!({ + "before": doc(), + "after": doc(), + "path": { "type": "string" }, + "all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." }, + }), + &["before", "after", "path"], + )), t("accessibility_fix", "Fix an accessibility problem", "Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order). Returns the rule's new status. Undoable.") .with(schema(json!({ "doc": doc(), "rule": { "type": "string", "enum": ["primary-language", "title", "tab-order"] }, "value": { "type": "string" } }), &["doc", "rule"])), t("accessibility_figures", "List figures", "The tagged figures (Figure elements, through the role map) in document order: figure number, page, alternate text and where the figure is drawn (top-left-origin points).") @@ -875,6 +886,16 @@ pub fn tools() -> Vec { t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.") .cmd("a11y.alt_text") .with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])), + t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.") + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.") + .with(schema(json!({ + "doc": doc(), "object": { "type": "integer", "minimum": 1 }, + "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] }, + "value": { "type": "string" }, + }), &["doc", "object", "generation", "property"])), t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c76117138..4008b3a5c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1565,6 +1565,35 @@ trailer << /Root 1 0 R >> ) .unwrap(); let fd = ok(&mut a, "doc_open", json!({ "path": "figures.pdf" }))["doc"].as_u64().unwrap(); + let tags = ok(&mut a, "accessibility_tags", json!({ "doc": fd })); + assert_eq!(tags["count"], 3); + assert_eq!(tags["tags"][1]["object"], 7); + assert_eq!(tags["tags"][1]["type"], "Figure"); + let changed = ok( + &mut a, + "accessibility_set_tag_property", + json!({ + "doc": fd, "object": 7, "generation": 0, "property": "title", "value": "First shape" + }), + ); + assert_eq!(changed["tags"][1]["title"], "First shape"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties"); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": fd }))["tags"][1]["title"], Value::Null); + assert!(matches!( + a.call( + "accessibility_set_tag_property", + &json!({ + "doc": fd, "object": 7, "generation": 1, "property": "title", "value": "Wrong generation" + }) + ), + Err(ToolError::Failed(_)) + )); + let findings = ok(&mut a, "accessibility_check", json!({ "doc": fd, "rules": ["figures-alt-text"] })); + let figures_rule = findings["results"].as_array().unwrap().iter().find(|x| x["rule"] == "figures-alt-text").unwrap(); + assert_eq!(figures_rule["findings"][0]["object"], 7); + assert_eq!(figures_rule["findings"][0]["object_generation"], 0); + assert_eq!(figures_rule["findings"][1]["object"], 8); + assert_eq!(figures_rule["findings"][1]["object_generation"], 0); let figs = ok(&mut a, "accessibility_figures", json!({ "doc": fd })); assert_eq!(figs["count"], 2); assert_eq!(figs["figures"][0]["rect"], json!([10.0, 30.0, 50.0, 50.0]), "top-left-origin points"); @@ -1579,6 +1608,36 @@ trailer << /Root 1 0 R >> assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_)))); } +#[test] +fn accessibility_review_record_compares_two_versions_without_changing_them() { + let dir = workdir("a11y-review-record"); + let source = std::fs::read(dir.join("a.pdf")).unwrap(); + let mut a = auto(&dir); + let before = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "accessibility_fix", json!({ "doc": after, "rule": "primary-language", "value": "en-GB" })); + + let output = ok(&mut a, "accessibility_review_record", json!({ "before": before, "after": after, "path": "review.json" })); + let file: Value = serde_json::from_slice(&std::fs::read(dir.join("review.json")).unwrap()).unwrap(); + assert_eq!(output["record"], file); + assert_eq!(file["schema"], 1); + assert_eq!(file["before"]["sha256"].as_str().unwrap().len(), 64); + assert_eq!(file["after"]["sha256"].as_str().unwrap().len(), 64); + assert_ne!(file["before"]["sha256"], file["after"]["sha256"]); + assert_eq!(file["changed"], json!(["primary-language"])); + let language = file["rules"].as_array().unwrap().iter().find(|r| r["rule"] == "primary-language").unwrap(); + assert_eq!(language["before"]["status"], "failed"); + assert_eq!(language["after"]["status"], "passed"); + assert!(file["review_required"].as_array().unwrap().iter().any(|r| r == "tagged-pdf")); + assert_eq!(std::fs::read(dir.join("a.pdf")).unwrap(), source); + assert!(matches!( + a.call("accessibility_review_record", &json!({ "before": before, "after": before, "path": "same.json" })), + Err(ToolError::InvalidArgs(_)) + )); + assert!(!dir.join("same.json").exists()); + assert!(a.call("accessibility_review_record", &json!({ "before": before, "after": after, "path": "../escape.json" })).is_err()); +} + #[test] fn editing_existing_text_through_tools() { let dir = workdir("edit-text"); diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index d2e818e1e..bc501e2d3 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -131,15 +131,25 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() { let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| Ok(boxed(0.5 + f64::from(id) * 0.01))); assert!(drawn.is_ok(), "{drawn:?}"); assert_eq!(fonts.len(), 1); + // The injected outlines are fixed; only the descriptor's family comes from craft-fonts. + // Check that metadata separately, then normalize those two fields to the original golden. + // Every drawing byte and every other object field must still match, in both build modes. + let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |face| face.family); + for (_, font) in fonts.iter() { + let descriptor = doc.dict(font).unwrap().get(b"FontDescriptor").and_then(Object::as_ref).unwrap(); + let dict = doc.dict(&Object::Ref(descriptor)).unwrap(); + assert_eq!(dict.name(b"FontName"), Some(family.replace(' ', "").as_bytes())); + assert_eq!(dict.get(b"FontFamily").and_then(Object::as_string).unwrap().bytes, family.as_bytes()); + doc.update_dict(descriptor, |d| { + d.set(b"FontName".to_vec(), Object::name("Arabic")); + d.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(b"Arabic".to_vec()))); + }) + .unwrap(); + } let mut bytes = out.clone(); pdfcraft_cos::serialize(&Object::Dict(fonts.clone()), &mut bytes); bytes.extend(snapshot(&doc)); - // The golden was recorded without craft-fonts: with an Arabic face built in, the font's - // FontName and FontFamily carry that face's name instead of "Arabic", so only the build - // without it (CI's Windows job) can compare bytes. - if pdfcraft_fonts::document_arabic_font().is_none() { - assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "output changed; the golden is the code before the sharding change"); - } + assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "output changed; the golden is the code before the sharding change"); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1931247a5..489a407f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -472,6 +472,11 @@ impl Document { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() } + /// Reachable indirect structure tags, in document order. + pub fn tags(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() + } + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language /// (`value` is the language), the title (`value` replaces it; else the current title or the /// file name) and the tab order. @@ -1184,6 +1189,12 @@ pub enum Edit { figure: u32, alt: Option, }, + /// Change one property on a reachable indirect structure tag. + SetTagProperty { + object: pdfcraft_cos::ObjRef, + property: a11y::TagProperty, + value: Option, + }, /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. MarkDecorative { figure: u32, @@ -1438,6 +1449,7 @@ impl std::fmt::Display for Label { /// [`BATCH_LABELS`]). pub const EDIT_LABELS: &[&str] = &[ "Rotate page", + "Change tag properties", "Rotate pages", "Delete page", "Delete pages", @@ -1639,6 +1651,7 @@ impl Edit { Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), Edit::AddOcrText { .. } => Label::of("Recognize text"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), @@ -1824,6 +1837,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::MoveInTabOrder { .. } | Edit::SetInitialView(_) | Edit::SetAltText { .. } + | Edit::SetTagProperty { .. } | Edit::MarkDecorative { .. } | Edit::AddOcrText { .. } | Edit::SetDocumentScript { .. } @@ -2093,6 +2107,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::EditTextLine { page, line, text } => { pdfcraft_edit::replace_line(doc, *page, *line, text)?; } diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 83b98de7a..3c9b1071d 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +craft-print = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } diff --git a/crates/print/README.md b/crates/print/README.md index 20b16f4f2..a7514023b 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -32,17 +32,20 @@ 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). -`spool` talks to CUPS on macOS and Linux: printers come from `lpstat -p -d` (the queues) and -`lpstat -e` (driverless destinations no queue exists for; CUPS builds a temporary one when the -job arrives), jobs go to `lp` with copies, collation, duplex and monochrome options. The job is -piped to `lp` on stdin and never written to a temp file, where another local user could read or -swap it. On Windows the printers come from `Win32_Printer` and jobs are rendered by the in-box -`Windows.Data.Pdf` and spooled by `System.Drawing.Printing` with the driver's own settings (both -through `powershell`, so no Win32 API is called here); that job file exists for the seconds the -spooler reads it, under a fresh unpredictable name. Each sheet prints at 100% on the driver's -paper closest to its size, turned landscape when it is wide, and is rasterised at the printer's -resolution (up to 600 dpi), not sent as vectors. Other platforms report that printing to a -printer isn't available yet; the print-ready PDF can always be saved. +`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. +CUPS jobs stream through stdin with `fit-to-page=false`. Windows renders sheets through +`Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and +orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex +use the corresponding Windows binding edges. Random, exclusively created Windows job files +are removed on success and failure. Other targets offer the print-ready PDF instead. + +The shared dependency requires Rust 1.95. Transport and native Windows regression tests +live in craft-libs; local tests retain composition, facade, engine, automation and UI coverage. +The desktop UI discovers printers and submits jobs on workers, so neither operation blocks +the dialog; the Windows backend starts without a console window. Physical printer output +still needs hardware validation. Not yet: the web spooler, print as image, poster labels, PostScript output, colour conversion for grayscale. diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 0d949aae6..bad3ab210 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -1,564 +1,15 @@ -//! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat` — its -//! queues, and the driverless destinations it can print to without one — and jobs are piped to -//! `lp` with the job options (copies, collation, duplex, colour). On Windows the printers come -//! from `Win32_Printer` and jobs are rendered with the in-box `Windows.Data.Pdf` and spooled -//! with the driver's own settings — both through `powershell`, so no Win32 API is called here. -//! Other platforms report that printing isn't available yet; the print-ready PDF can still be -//! saved. +//! System print spooler supplied by craft-print. PDF composition and permission checks +//! stay in PdfCraft; these adapters retain the application's print-error surface. -use crate::PrintError; +pub use craft_print::{ + Duplex, HANDLED_OPTIONS, HAS_PRINTER_PREFERENCES, Job, Printer, PrinterOption, Scaling, lp_args, lpstat_command, parse_lpoptions, parse_lpstat, + parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printer_options, printers, printers_parsed, +}; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Printer { - pub name: String, - pub default: bool, - /// Whether the driver says it can print two-sided, where the spooler tells (Windows: the - /// printer's `Duplex Printing` capability). A driver without it ignores Two-sided and uses - /// its own setting. `None` when unknown (CUPS maps `sides` itself). - pub two_sided: Option, +pub fn submit(pdf: &[u8], job: &Job) -> Result { + craft_print::submit(pdf, job).map_err(|e| crate::PrintError::Spool(e.to_string())) } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Duplex { - #[default] - Off, - LongEdge, - ShortEdge, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Job { - /// `None` = the system default printer. - pub printer: Option, - pub copies: u32, - pub collate: bool, - pub duplex: Duplex, - pub grayscale: bool, - pub title: String, - /// The printer driver's own job options (a PPD keyword and the chosen value), such as the - /// paper tray or paper type; see [`printer_options`]. CUPS only. - pub options: Vec<(String, String)>, -} - -impl Default for Job { - fn default() -> Self { - Job { printer: None, copies: 1, collate: true, duplex: Duplex::Off, grayscale: false, title: "PdfCraft".into(), options: Vec::new() } - } -} - -/// The `lpstat -d` line's destination name, when the spooler has a default. -fn default_destination(out: &str) -> Option<&str> { - out.lines().find_map(|l| l.strip_prefix("system default destination:")).map(str::trim) -} - -/// Parse `lpstat -p -d` output. -pub fn parse_lpstat(out: &str) -> Vec { - let default = default_destination(out); - out.lines() - .filter_map(|l| l.strip_prefix("printer ")) - .filter_map(|l| l.split_whitespace().next()) - .map(|n| Printer { name: n.to_string(), default: default == Some(n), two_sided: None }) - .collect() -} - -/// Parse `lpstat -e` output: destination names, one per line. -pub fn parse_lpstat_e(out: &str) -> Vec { - out.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect() -} - -/// The `lp` arguments for a job. There is no file argument: `lp` reads the job from stdin. -pub fn lp_args(job: &Job) -> Vec { - let mut a = Vec::new(); - if let Some(p) = &job.printer { - a.extend(["-d".to_string(), p.clone()]); - } - a.extend(["-n".to_string(), job.copies.clamp(1, 999).to_string()]); - a.extend(["-t".to_string(), job.title.clone()]); - let mut opt = |o: &str| a.extend(["-o".to_string(), o.to_string()]); - opt(if job.collate { "collate=true" } else { "collate=false" }); - opt(match job.duplex { - Duplex::Off => "sides=one-sided", - Duplex::LongEdge => "sides=two-sided-long-edge", - Duplex::ShortEdge => "sides=two-sided-short-edge", - }); - if job.grayscale { - opt("print-color-mode=monochrome"); - } - // The sheets are already laid out at their final size. - opt("fit-to-page=false"); - for (key, value) in &job.options { - // Driver options are PPD keywords; anything else is not something lp can be given. - if is_ppd_keyword(key) && is_ppd_keyword(value) && !HANDLED_OPTIONS.contains(&key.as_str()) { - a.extend(["-o".to_string(), format!("{key}={value}")]); - } - } - a -} - -/// One of the printer driver's job options (a PPD `OpenUI` entry), such as the paper tray. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PrinterOption { - /// The PPD keyword (`InputSlot`). - pub key: String, - /// What the driver calls it (`Paper tray`); the keyword when it gives no name. - pub label: String, - /// The driver's group (`Paper Handling`); empty outside a group. - pub group: String, - /// The choices: keyword and label (`Tray2`, `Tray 2`). - pub choices: Vec<(String, String)>, - /// The choice the printer uses when a job doesn't say. - pub default: String, -} - -/// Options PdfCraft's Print dialog sets itself (paper, two-sided, collation), so the driver's -/// copies of them are neither shown nor sent. -pub const HANDLED_OPTIONS: &[&str] = &["PageSize", "PageRegion", "Duplex", "Collate", "ImageableArea", "PaperDimension"]; - -/// Caps on what a PPD may make the dialog hold (PPDs are text files from the printer vendor). -const MAX_OPTIONS: usize = 400; -const MAX_CHOICES: usize = 400; - -/// A PPD keyword: printable ASCII without spaces, `/`, `:` or `=`. -fn is_ppd_keyword(s: &str) -> bool { - !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b':' | b'=' | b'"')) -} - -/// `Name/Label` → (`Name`, `Label`), the label falling back to the name. -fn name_label(s: &str) -> (&str, &str) { - match s.split_once('/') { - Some((n, l)) if !l.trim().is_empty() => (n.trim(), l.trim()), - Some((n, _)) => (n.trim(), n.trim()), - None => (s.trim(), s.trim()), - } -} - -/// The job options a PPD offers, in file order, without the installable-hardware group and the -/// options PdfCraft sets itself ([`HANDLED_OPTIONS`]). Lenient: malformed lines are skipped. -pub fn parse_ppd(ppd: &str) -> Vec { - let mut out: Vec = Vec::new(); - let (mut group, mut group_key) = (String::new(), String::new()); - let mut open: Option = None; - for line in ppd.lines() { - let line = line.trim_end_matches('\r'); - if let Some(rest) = line.strip_prefix("*OpenGroup:") { - let (k, l) = name_label(rest); - (group_key, group) = (k.to_string(), l.to_string()); - } else if line.starts_with("*CloseGroup") { - (group_key, group) = (String::new(), String::new()); - } else if let Some(rest) = line.strip_prefix("*OpenUI") { - let Some((head, _kind)) = rest.trim().rsplit_once(':') else { continue }; - let (key, label) = name_label(head.trim().trim_start_matches('*')); - open = is_ppd_keyword(key).then(|| PrinterOption { - key: key.to_string(), - label: label.to_string(), - group: group.clone(), - choices: Vec::new(), - default: String::new(), - }); - } else if line.starts_with("*CloseUI") { - if let Some(o) = open.take() - && !o.choices.is_empty() - && group_key != "InstallableOptions" - && !HANDLED_OPTIONS.contains(&o.key.as_str()) - && out.len() < MAX_OPTIONS - { - out.push(o); - } - } else if let Some(o) = open.as_mut() { - let Some(rest) = line.strip_prefix('*') else { continue }; - if let Some(value) = rest.strip_prefix("Default").and_then(|r| r.strip_prefix(o.key.as_str())).and_then(|r| r.strip_prefix(':')) { - o.default = value.trim().to_string(); - } else if let Some(rest) = rest.strip_prefix(o.key.as_str()).and_then(|r| r.strip_prefix(' ')) { - // `*InputSlot Tray2/Tray 2: "<< ... >>"`; the code after the colon is the driver's. - let head = rest.split_once(':').map_or(rest, |(h, _)| h); - let (choice, label) = name_label(head); - if is_ppd_keyword(choice) && o.choices.len() < MAX_CHOICES && o.choices.iter().all(|(c, _)| c != choice) { - o.choices.push((choice.to_string(), label.to_string())); - } - } - } - } - for o in &mut out { - if !o.choices.iter().any(|(c, _)| *c == o.default) { - o.default = o.choices.first().map(|(c, _)| c.clone()).unwrap_or_default(); - } - } - out -} - -/// The current choice of each option in `lpoptions -p NAME -l` output (`Key/Label: a *b c`): the -/// queue's defaults, including the user's own (`~/.cups/lpoptions`). -pub fn parse_lpoptions(out: &str) -> Vec<(String, String)> { - out.lines() - .filter_map(|l| { - let (head, choices) = l.split_once(':')?; - let (key, _) = name_label(head); - let current = choices.split_whitespace().find_map(|c| c.strip_prefix('*'))?; - Some((key.to_string(), current.to_string())) - }) - .collect() -} - -/// A PPD's text: UTF-8, or ISO Latin-1 (the PPD default) when it isn't valid UTF-8. -pub fn ppd_text(bytes: &[u8]) -> String { - match std::str::from_utf8(bytes) { - Ok(s) => s.to_string(), - Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), - } -} - -/// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); -/// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it -/// when the user asks, not every frame. -pub fn printer_options(printer: &str) -> Vec { - #[cfg(all(unix, not(target_arch = "wasm32")))] - { - // Queue names never contain `/`; anything else is not a queue and not a path to follow. - if !is_ppd_keyword(printer) { - return Vec::new(); - } - const PPD_CAP: u64 = 16 << 20; - let ppd = std::fs::File::open(format!("/etc/cups/ppd/{printer}.ppd")).ok().and_then(|f| { - use std::io::Read as _; - let mut bytes = Vec::new(); - f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) - }); - let mut options = ppd.map(|b| parse_ppd(&ppd_text(&b))).unwrap_or_default(); - let current = std::process::Command::new("lpoptions") - .args(["-p", printer, "-l"]) - .output() - .map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout))) - .unwrap_or_default(); - for (key, value) in current { - if let Some(o) = options.iter_mut().find(|o| o.key == key) - && o.choices.iter().any(|(c, _)| *c == value) - { - o.default = value; - } - } - options - } - #[cfg(not(all(unix, not(target_arch = "wasm32"))))] - { - let _ = printer; - Vec::new() - } -} - -/// Whether [`open_printer_preferences`] can show the driver's own settings window (Windows). -pub const HAS_PRINTER_PREFERENCES: bool = cfg!(windows); - -/// Open the printer driver's Printing Preferences window (Windows: `printui.dll`). What the user -/// sets there is saved as that printer's defaults for this Windows user, which every job starts -/// from; the Print dialog's copies, two-sided, grayscale and paper still apply on top. -pub fn open_printer_preferences(printer: &str) -> Result<(), PrintError> { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - // printui takes the name in quotes; Windows printer names can't contain one anyway. - if printer.is_empty() || printer.contains('"') { - return Err(PrintError::Spool("not a printer name".into())); - } - let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); - let rundll = std::path::Path::new(&root).join("System32").join("rundll32.exe"); - std::process::Command::new(rundll) - .raw_arg("printui.dll,PrintUIEntry") - .raw_arg("/e") - .raw_arg("/n") - .raw_arg(format!("\"{printer}\"")) - .spawn() - .map(|_| ()) - .map_err(|e| PrintError::Spool(format!("the printer's preferences could not be opened: {e}"))) - } - #[cfg(not(windows))] - { - let _ = printer; - Err(PrintError::Spool("printer preferences open only on Windows; use the printer's options instead".into())) - } -} - -/// `lpstat`, forced to print untranslated messages so [`parse_lpstat`] and [`parse_lpstat_e`] can -/// read them. -/// -/// `LC_ALL`/`LANG=C` is enough on Linux. macOS CUPS ignores them and follows the user's -/// interface language (`AppleLanguages`) unless `SOFTWARE` is set, in which case it uses `LANG`. -fn lpstat_with(args: &[&str]) -> std::process::Command { - let mut c = std::process::Command::new("lpstat"); - c.args(args).env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "PdfCraft"); - c -} - -/// `lpstat -p -d`: the spooler's queues and the default destination. -pub fn lpstat_command() -> std::process::Command { - lpstat_with(&["-p", "-d"]) -} - -/// `lpstat -e`: every destination CUPS can print to, including driverless network printers no -/// permanent queue exists for (a queue is built only when a job needs one). GTK's and macOS's -/// print dialogs list them the same way. CUPS ≥ 1.7 (2013). -/// -/// Gated like its only caller, `printers`' unix block: off unix this is dead code, and the -/// Windows clippy gate builds with `-D warnings`. -#[cfg(all(unix, not(target_arch = "wasm32")))] -fn lpstat_e_command() -> std::process::Command { - lpstat_with(&["-e"]) -} - -/// The printers the system knows (empty when there are none or no spooler): its queues, then the -/// driverless destinations CUPS can print to without one. -pub fn printers() -> Vec { - #[cfg(all(unix, not(target_arch = "wasm32")))] - { - let Some(queues) = lpstat_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) else { - return Vec::new(); - }; - // A spooler without `-e`, or one whose discovery times out, only loses the driverless names. - let available = lpstat_e_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); - printers_parsed(&queues, available.as_deref()) - } - #[cfg(windows)] - { - // Win32_Printer through CIM: `wmic` was removed from Windows 11 (24H2) and - // `Get-CimInstance` is on every desktop install. A machine whose PowerShell is locked - // down lists no printers and the dialog offers only Save as PDF, as with CUPS that - // isn't running. - match powershell_command(POWERSHELL_PRINTERS).output() { - Ok(o) if o.status.success() => parse_windows_printers(&String::from_utf8_lossy(&o.stdout)), - _ => Vec::new(), - } - } - #[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))] - { - Vec::new() - } -} - -/// The Print dialog's printers from lpstat's two answers: the spooler's queues (`-p -d`), then -/// the driverless destinations of `available` (`-e`) that have no queue — CUPS builds a temporary -/// queue when `lp` sends them a job. Each destination once, queues first; the default still comes -/// from `-d`, so a driverless default keeps its marker. `None` when the spooler doesn't know `-e` -/// (CUPS < 1.7, 2013), which loses only the driverless names. -pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec { - let mut printers = parse_lpstat(queues); - let default = default_destination(queues); - if let Some(names) = available { - for name in parse_lpstat_e(names) { - if !printers.iter().any(|p| p.name == name) { - printers.push(Printer { default: default == Some(name.as_str()), name, two_sided: None }); - } - } - } - printers -} - -/// The `powershell` command that runs a script. `-NoProfile` keeps a user's profile from -/// writing banners into what is parsed, `-NonInteractive` from waiting for input. -/// `CREATE_NO_WINDOW`: the release app has no console of its own, so without it Windows opens -/// a console window for PowerShell over the app for as long as the script runs. -/// -/// Named by its full path, as `rundll32` is in [`open_printer_preferences`]: given a bare name, -/// Rust looks in the app's own folder before System32, so a `powershell.exe` beside a portable -/// `pdfcraft.exe` would run in its place each time the Print dialog opens and on every job. -#[cfg(windows)] -pub(crate) fn powershell_command(script: &str) -> std::process::Command { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); - let exe = std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"); - let mut c = std::process::Command::new(exe); - c.args(["-NoProfile", "-NonInteractive", "-Command", script]); - c.creation_flags(CREATE_NO_WINDOW); - c -} - -/// The Windows printer list: one line per printer, `*` on the system default, then a tab and -/// `2` when the driver reports the Duplex Printing capability (3), `1` when it doesn't. UTF-8, -/// through the output encoding the script sets first. Only the properties the list needs are -/// pulled, so a machine with many printers doesn't wait for the rest of each record. -#[cfg(windows)] -const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default, Capabilities | ForEach-Object { $sides = if (@($_.Capabilities) -contains 3) { '2' } else { '1' }; if ($_.Default) { '*' + $_.Name + [char]9 + $sides } else { $_.Name + [char]9 + $sides } }"; - -/// The Print dialog's printers from the Windows printer list: one per line, `*` before the -/// system default (a Windows queue name may not contain `*`), and after a tab `2` or `1` for -/// whether the driver can print two-sided. The first `*` wins when a broken spooler marks more -/// than one, so the dialog still has a single default to preselect. -pub fn parse_windows_printers(out: &str) -> Vec { - let mut printers: Vec = out - .lines() - .map(|l| l.strip_suffix('\r').unwrap_or(l)) - .filter(|l| !l.is_empty()) - .map(|l| { - let (l, two_sided) = match l.rsplit_once('\t') { - Some((name, "2")) => (name, Some(true)), - Some((name, "1")) => (name, Some(false)), - _ => (l, None), - }; - match l.strip_prefix('*') { - Some(name) => Printer { name: name.to_string(), default: true, two_sided }, - None => Printer { name: l.to_string(), default: false, two_sided }, - } - }) - .collect(); - let mut seen_default = false; - for p in &mut printers { - if p.default { - p.default = !seen_default; - seen_default = true; - } - } - printers -} - -/// Send a print-ready PDF to the spooler. Returns the spooler's message (the job id). -pub fn submit(pdf: &[u8], job: &Job) -> Result { - #[cfg(all(unix, not(target_arch = "wasm32")))] - { - submit_via(std::process::Command::new("lp"), pdf, job) - } - #[cfg(windows)] - { - windows_submit(pdf, job, &[]) - } - #[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))] - { - let _ = (pdf, job); - Err(PrintError::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into())) - } -} - -/// [`submit`] on Windows: the print-ready PDF goes to a job file of its own (WinRT reads from -/// a path), the embedded [`WINDOWS_PRINT_SCRIPT`] spools it, and the file is gone again however -/// the job ended. `extra_envs` reaches the script too — the tests use it for -/// `PDFCRAFT_PRINT_DRYRUN`, which renders without a printer. -#[cfg(windows)] -pub(crate) fn windows_submit(pdf: &[u8], job: &Job, extra_envs: &[(&str, &str)]) -> Result { - let path = windows_job_file(pdf)?; - let mut ps = powershell_command(WINDOWS_PRINT_SCRIPT); - for (key, value) in windows_env_vars(&path, job).into_iter().chain(extra_envs.iter().map(|(k, v)| (k.to_string(), v.to_string()))) { - ps.env(key, value); - } - let out = ps.output(); - // Remove the job file before reporting: a spooler that refused the job must not leave the - // document behind. - let _ = std::fs::remove_file(&path); - let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?; - if !out.status.success() { - let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); - let err = if err.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_string() } else { err }; - return Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err })); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -/// The job file [`windows_submit`] hands to `powershell`, in this user's own temporary folder: -/// a fresh name no other local user can predict (`create_new`, with the process id and the -/// time), written before anything is spawned and removed after. WinRT reads a path, so unlike -/// the job piped to `lp` this one touches disk for the seconds the spooler reads it. -#[cfg(windows)] -fn windows_job_file(pdf: &[u8]) -> Result { - use std::io::Write as _; - let written = |e: std::io::Error| PrintError::Spool(format!("the print job could not be written: {e}")); - let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); - for attempt in 0..64u32 { - let path = std::env::temp_dir().join(format!("pdfcraft-print-{}-{nanos}-{attempt}.pdf", std::process::id())); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { - Ok(mut file) => { - file.write_all(pdf).map_err(written)?; - return Ok(path); - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(written(e)), - } - } - Err(PrintError::Spool("the print job could not be written: no free name in the temporary folder".into())) -} - -/// The print script's environment: no arguments, so a printer name with quotes or spaces -/// survives the command line and the document's path is not visible in the process list while -/// it prints. The driver options in [`Job::options`] are CUPS' PPD keywords; on Windows the -/// driver's own per-printer preferences apply (Print ▸ Properties…), so they are not sent. -#[cfg_attr(not(windows), allow(dead_code))] -pub(crate) fn windows_env_vars(path: &std::path::Path, job: &Job) -> Vec<(String, String)> { - vec![ - ("PDFCRAFT_PRINT_FILE".into(), path.to_string_lossy().into_owned()), - ("PDFCRAFT_PRINT_PRINTER".into(), job.printer.clone().unwrap_or_default()), - ("PDFCRAFT_PRINT_COPIES".into(), job.copies.to_string()), - ("PDFCRAFT_PRINT_COLLATE".into(), if job.collate { "1" } else { "0" }.into()), - ( - "PDFCRAFT_PRINT_DUPLEX".into(), - match job.duplex { - Duplex::Off => "off", - Duplex::LongEdge => "long-edge", - Duplex::ShortEdge => "short-edge", - } - .into(), - ), - ("PDFCRAFT_PRINT_GRAYSCALE".into(), if job.grayscale { "1" } else { "0" }.into()), - ("PDFCRAFT_PRINT_TITLE".into(), job.title.clone()), - ] -} - -/// The embedded print script (see [`windows_submit`]). -#[cfg(windows)] -pub(crate) const WINDOWS_PRINT_SCRIPT: &str = include_str!("windows_print.ps1"); - -/// [`submit`] with the spooler command given, so tests can stand in for `lp`. -/// -/// The job goes to `lp` on stdin, never through a file: a predictable job folder in a shared -/// temp directory lets another local user read printed documents or swap the file before `lp` -/// reads it. -#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] -pub(crate) fn submit_via(mut lp: std::process::Command, pdf: &[u8], job: &Job) -> Result { - use std::io::Write; - use std::process::Stdio; - let mut child = lp - .args(lp_args(job)) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?; - let stdin = child.stdin.take(); - // Feed the job on its own thread while collecting the output, so neither side can fill a - // pipe and wait on the other. Dropping `stdin` at the end closes it: the end of the job. - let fed_and_out = std::thread::scope(|s| { - let feeder = std::thread::Builder::new().name("print job".into()).spawn_scoped(s, move || match stdin { - Some(mut stdin) => stdin.write_all(pdf), - None => Err(std::io::Error::other("no pipe to the spooler")), - }); - match feeder { - Ok(feeder) => { - let out = child.wait_with_output(); - Ok((feeder.join(), out)) - } - Err(e) => { - // No thread to feed it: stop `lp` rather than leave it with an empty job. - let _ = child.kill(); - let _ = child.wait(); - Err(PrintError::Spool(format!("the print job could not be sent to the spooler: {e}"))) - } - } - }); - let (fed, out) = fed_and_out?; - let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?; - if !out.status.success() { - // `lp` may refuse before reading the job (an unknown printer); its message says why, - // and the broken pipe that leaves behind does not. - let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); - return Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err })); - } - match fed { - Ok(Ok(())) => Ok(job_message(&out.stdout)), - Ok(Err(e)) => Err(PrintError::Spool(format!("the print job could not be sent to the spooler: {e}"))), - Err(_) => Err(PrintError::Spool("the print job could not be sent to the spooler".into())), - } -} - -/// `lp`'s reply ("request id is Office-12"), without the "(0 file(s))" CUPS adds when the job came -/// on stdin: it reads as if nothing was sent. -#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] -pub(crate) fn job_message(stdout: &[u8]) -> String { - let s = String::from_utf8_lossy(stdout); - let s = s.trim(); - s.strip_suffix("(0 file(s))").map_or(s, str::trim_end).to_string() +pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { + craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string())) } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index bf186fb7d..dd4dbbbb3 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::*; -use crate::spool::{Duplex, Job, Printer, lp_args, parse_lpstat, parse_windows_printers, windows_env_vars}; +use crate::spool::{Duplex, Job, Printer, lp_args, parse_lpstat, parse_windows_printers}; /// `n` pages of 200×300 (page 3 is landscape 300×200 when n ≥ 3); page i shows "(Page i+1)". /// Page 1 carries a printable square comment, a non-printing note, and a stamp. @@ -244,10 +244,11 @@ fn spooler_arguments_and_printer_list() { printer: Some("Office_Laser".into()), copies: 3, collate: false, - duplex: Duplex::LongEdge, + duplex: Some(Duplex::LongEdge), grayscale: true, title: "memo.pdf".into(), options: Vec::new(), + ..Job::default() }; assert_eq!( lp_args(&job).join(" "), @@ -360,90 +361,6 @@ fn multiple_rejects_hostile_grids_without_panicking() { } } -/// Set on the child process when a test runs this test binary as a stand-in for `lp`. -const STAND_IN_LP: &str = "PDFCRAFT_STAND_IN_LP"; - -/// Not a test of its own: [`stand_in_lp`] re-runs this binary with only this test selected, and it -/// then plays `lp`. It records its arguments and stdin in the folder named by [`STAND_IN_LP`], or, -/// when the folder is `refuse`, exits without reading stdin, the way `lp` refuses an unknown -/// printer. -#[test] -fn stand_in_for_lp() { - let Some(record) = std::env::var_os(STAND_IN_LP) else { return }; - if record == "refuse" { - eprintln!("lp: The printer or class does not exist."); - std::process::exit(1); - } - let record = std::path::PathBuf::from(record); - let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); - let mut stdin = Vec::new(); - std::io::Read::read_to_end(&mut std::io::stdin(), &mut stdin).unwrap(); - std::fs::write(record.join("args"), args.join(" ")).unwrap(); - std::fs::write(record.join("stdin"), stdin).unwrap(); -} - -/// A spooler command that runs [`stand_in_for_lp`] with `record` (see there). -fn stand_in_lp(record: &std::ffi::OsStr) -> std::process::Command { - let mut c = std::process::Command::new(std::env::current_exe().unwrap()); - c.args(["--exact", "tests::stand_in_for_lp", "--nocapture", "--"]).env(STAND_IN_LP, record); - c -} - -#[test] -fn print_jobs_reach_lp_on_stdin_never_through_a_shared_temp_folder() { - // The job used to be written to `/pdfcraft-print-/job-