Merge main into smooth-scrolling-preference

Conflicts:
- ROADMAP.md, docs/roadmap.md: both sides added a status line, log entries
  and revision rows at the same places; all are kept, newest first.
- ATTRIBUTION.toml: both sides changed the 20 translation catalogs, which
  merged cleanly; their hashes are recomputed from the merged files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jeremy Yao
2026-10-11 20:34:39 -04:00
co-authored by Claude Opus 5.5
57 changed files with 989 additions and 1040 deletions
+20 -20
View File
@@ -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"
Generated
+15 -5
View File
@@ -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]]
+2
View File
@@ -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" }
+1 -1
View File
@@ -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.
+4 -1
View File
@@ -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.
+14
View File
@@ -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"));
}
+3
View File
@@ -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
+1 -1
View File
@@ -48,7 +48,7 @@ pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
}
/// Indirect structure elements in document order, with their page.
fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
pub(crate) fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = HashSet::new();
+7 -3
View File
@@ -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<usize>,
/// Indirect structure element that caused the finding, when one is available.
pub object: Option<pdfcraft_cos::ObjRef>,
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<usize>, 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<usize>, 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<usize>, 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.
+10 -5
View File
@@ -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<ObjRef>,
/// The standard type after the role map, or the type as written when it maps to nothing.
ty: Vec<u8>,
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<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, v: &mut V) {
fn dict<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, object: Option<ObjRef>, 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<usize> {
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.
+111
View File
@@ -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<String>,
pub actual_text: Option<String>,
pub alt: Option<String>,
pub language: Option<String>,
/// Zero-based page when `/Pg` is known.
pub page: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagProperty {
Type,
Title,
ActualText,
AlternateText,
Language,
}
impl TagProperty {
pub fn from_id(id: &str) -> Option<Self> {
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<String> {
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<TagInfo> {
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = 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(())
}
+42
View File
@@ -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
+2 -1
View File
@@ -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"] }
+9
View File
@@ -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
+120 -1
View File
@@ -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<Value> = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect();
let findings: Vec<Value> = 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<Value> {
let id = |key| -> Result<DocId> {
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::<String>();
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<Value> {
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<Value> {
let tags: Vec<Value> = 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<Value> {
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 {
+3
View File
@@ -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" => {
+3 -2
View File
@@ -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") {
+21
View File
@@ -866,6 +866,17 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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<ToolDef> {
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"])),
+59
View File
@@ -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");
+16 -6
View File
@@ -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]
+17
View File
@@ -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<a11y::TagInfo> {
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<String>,
},
/// Change one property on a reachable indirect structure tag.
SetTagProperty {
object: pdfcraft_cos::ObjRef,
property: a11y::TagProperty,
value: Option<String>,
},
/// 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)?;
}
+1
View File
@@ -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 }
+14 -11
View File
@@ -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.
+10 -559
View File
@@ -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<bool>,
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
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<String>,
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<Printer> {
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<String> {
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<String> {
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<PrinterOption> {
let mut out: Vec<PrinterOption> = Vec::new();
let (mut group, mut group_key) = (String::new(), String::new());
let mut open: Option<PrinterOption> = 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<PrinterOption> {
#[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<Printer> {
#[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<Printer> {
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<Printer> {
let mut printers: Vec<Printer> = 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<String, PrintError> {
#[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<String, PrintError> {
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<std::path::PathBuf, PrintError> {
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<String, PrintError> {
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()))
}
+3 -214
View File
@@ -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<String> = 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 `<temp>/pdfcraft-print-<pid>/job-<time>.pdf` and handed to `lp`
// by name. In a shared /tmp another local user can predict that folder, create it first (or
// plant a symlink there) and so read every printed document or swap the file before `lp`
// reads it. Piping the job to `lp` leaves nothing on disk.
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let record =
std::env::temp_dir().join(format!("pdfcraft-print-test-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
std::fs::create_dir(&record).unwrap();
// Bigger than any pipe buffer, so the spooler must read while the job is still being written.
let pdf: Vec<u8> = b"%PDF-1.7 synthetic print job\n".iter().copied().cycle().take(3 << 20).collect();
let job = Job { title: "memo.pdf".into(), ..Job::default() };
let sent = spool::submit_via(stand_in_lp(record.as_os_str()), &pdf, &job);
let args = std::fs::read_to_string(record.join("args"));
let stdin = std::fs::read(record.join("stdin"));
let _ = std::fs::remove_dir_all(&record);
assert!(sent.is_ok(), "{sent:?}");
assert_eq!(
args.unwrap(),
"-n 1 -t memo.pdf -o collate=true -o sides=one-sided -o fit-to-page=false",
"no file argument: lp reads the job from stdin"
);
assert!(stdin.unwrap() == pdf, "lp receives the whole job on stdin");
let predictable = std::env::temp_dir().join(format!("pdfcraft-print-{}", std::process::id()));
assert!(!predictable.exists(), "nothing is created at {predictable:?}, a name other local users can predict");
}
#[test]
fn a_refused_print_job_reports_the_spoolers_message() {
// `lp` refuses an unknown printer without reading the job; the user sees why, not a broken pipe.
let pdf = vec![b'%'; 3 << 20];
let err = spool::submit_via(stand_in_lp("refuse".as_ref()), &pdf, &Job::default());
assert_eq!(err, Err(PrintError::Spool("lp: The printer or class does not exist.".into())));
}
#[test]
fn the_spoolers_reply_drops_the_file_count_of_a_job_sent_on_stdin() {
assert_eq!(
spool::job_message(
b"request id is Office_Laser-12 (0 file(s))
"
),
"request id is Office_Laser-12"
);
assert_eq!(
spool::job_message(
b"request id is Office_Laser-13 (1 file(s))
"
),
"request id is Office_Laser-13 (1 file(s))"
);
assert_eq!(spool::job_message(b""), "");
}
#[test]
fn windows_printers_carry_one_default_and_keep_their_names() {
// Windows marks the system default with a `*` no queue name may contain; names hold spaces,
@@ -462,134 +379,6 @@ fn windows_printers_carry_one_default_and_keep_their_names() {
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) });
}
#[test]
fn the_windows_print_script_binds_long_edge_on_the_long_edge() {
// System.Drawing's Duplex values are DEVMODE's: Vertical binds a portrait page on its long
// edge, Horizontal on its short edge (checked against a PCL driver's @PJL SET BINDING).
let script = include_str!("windows_print.ps1").replace("\r\n", "\n");
assert!(script.contains("'long-edge' { [System.Drawing.Printing.Duplex]::Vertical }"));
assert!(script.contains("'short-edge' { [System.Drawing.Printing.Duplex]::Horizontal }"));
}
#[test]
fn the_windows_job_is_the_environment_of_the_print_script() {
let path = std::path::Path::new("C:\\Users\\me\\AppData\\Local\\Temp\\pdfcraft-print-42.pdf");
let job = Job {
printer: Some("HP \"Laser\" Jet".into()),
copies: 3,
duplex: Duplex::ShortEdge,
grayscale: true,
title: "Angebot.pdf".into(),
..Job::default()
};
let vars = windows_env_vars(path, &job);
let env = |key: &str| vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()).unwrap();
assert_eq!(env("PDFCRAFT_PRINT_FILE"), "C:\\Users\\me\\AppData\\Local\\Temp\\pdfcraft-print-42.pdf");
// Quotes and spaces in a printer name survive because nothing goes on the command line.
assert_eq!(env("PDFCRAFT_PRINT_PRINTER"), "HP \"Laser\" Jet");
assert_eq!(env("PDFCRAFT_PRINT_COPIES"), "3");
assert_eq!(env("PDFCRAFT_PRINT_COLLATE"), "1");
assert_eq!(env("PDFCRAFT_PRINT_DUPLEX"), "short-edge");
assert_eq!(env("PDFCRAFT_PRINT_GRAYSCALE"), "1");
assert_eq!(env("PDFCRAFT_PRINT_TITLE"), "Angebot.pdf");
// No printer chosen means the system default: an empty name, not the word "default".
let one = |j: &Job, key: &str| windows_env_vars(path, j).into_iter().find(|(k, _)| k == key).unwrap().1;
assert_eq!(one(&Job::default(), "PDFCRAFT_PRINT_PRINTER"), "");
assert_eq!(one(&Job { duplex: Duplex::Off, ..Job::default() }, "PDFCRAFT_PRINT_DUPLEX"), "off");
assert_eq!(one(&Job { duplex: Duplex::LongEdge, ..Job::default() }, "PDFCRAFT_PRINT_DUPLEX"), "long-edge");
}
/// Run a PowerShell script for the Windows-only tests: its stdout, or everything it said when
/// it failed — the tests print that as the skip reason.
#[cfg(windows)]
fn windows_powershell(script: &str, envs: &[(&str, &str)]) -> Result<String, String> {
let mut c = std::process::Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", script]);
for (k, v) in envs {
c.env(k, v);
}
match c.output() {
Ok(o) if o.status.success() => Ok(String::from_utf8_lossy(&o.stdout).into_owned()),
Ok(o) => Err(format!("{} {}", String::from_utf8_lossy(&o.stderr).trim(), String::from_utf8_lossy(&o.stdout).trim())),
Err(e) => Err(e.to_string()),
}
}
/// Windows: the printer list comes from the spooler. A bare machine may have no printer; the
/// list that exists has real names and at most one default.
#[cfg(windows)]
#[test]
fn windows_lists_the_printers_the_system_knows() {
let printers = spool::printers();
assert!(printers.iter().all(|p| !p.name.trim().is_empty()), "{printers:?}");
assert!(printers.iter().filter(|p| p.default).count() <= 1, "{printers:?}");
}
/// Windows: PowerShell is started by its full path under System32, never by its bare name.
/// Rust resolves a bare name in the app's own folder first, so a `powershell.exe` beside a
/// portable install would run instead, for every printer list and every job.
#[cfg(windows)]
#[test]
fn windows_starts_powershell_by_its_full_path() {
let c = crate::spool::powershell_command("$null");
let program = std::path::Path::new(c.get_program());
assert!(program.is_absolute(), "{program:?}");
assert!(program.ends_with("System32/WindowsPowerShell/v1.0/powershell.exe"), "{program:?}");
assert!(program.is_file(), "{program:?}");
}
/// Windows: the embedded print script is valid PowerShell, checked on every machine — including
/// runners that have no printer to print to.
#[cfg(windows)]
#[test]
fn the_windows_print_script_parses() {
let path = std::env::temp_dir().join(format!("pdfcraft-print-script-{}.ps1", std::process::id()));
std::fs::write(&path, spool::WINDOWS_PRINT_SCRIPT).unwrap();
let errors = windows_powershell(
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile($env:PDFCRAFT_TEST_SCRIPT, [ref]$tokens, [ref]$errors) > $null; $errors.Count",
&[("PDFCRAFT_TEST_SCRIPT", path.to_str().unwrap())],
);
let _ = std::fs::remove_file(&path);
assert_eq!(errors.as_deref().map(str::trim), Ok("0"), "parse errors: {errors:?}");
}
/// Windows, end to end: the script renders the job's sheets with Windows.Data.Pdf on any
/// machine — CI included, no printer needed (the dry run writes the PNGs to the temporary
/// folder) — and, where a paused test printer can be added, a real job lands in its queue.
/// Its driver is the inbox "Generic / Text Only"; a machine that cannot add one (no rights, no
/// driver) skips that half, as the CUPS tests skip where CUPS isn't running.
#[cfg(windows)]
#[test]
fn the_print_script_renders_the_job_and_reaches_the_spooler() {
let pdf = impose(&fixture(2), &settings(vec![0, 1], Layout::Size(SizeMode::Fit))).unwrap();
// The printer name is deliberately wrong: the dry run stops before the driver is touched.
let job = Job { printer: Some("no such printer".into()), title: "test.pdf".into(), ..Job::default() };
let dry = spool::windows_submit(&pdf, &job, &[("PDFCRAFT_PRINT_DRYRUN", "1")]);
assert_eq!(dry.as_deref(), Ok("rendered 2 sheet(s)"), "{dry:?}");
const NAME: &str = "PdfCraft Test Print";
let added = windows_powershell(
&format!(
"if (-not (Get-Printer -Name '{NAME}' -ErrorAction SilentlyContinue)) {{ Add-Printer -Name '{NAME}' -DriverName 'Generic / Text Only' -PortName 'FILE:' }}; Set-Printer -Name '{NAME}' -IsPaused $true"
),
&[],
);
if let Err(why) = added {
eprintln!("skipping the real print: the test printer could not be prepared: {why}");
return;
}
let job = Job { printer: Some(NAME.into()), title: "test.pdf".into(), ..Job::default() };
let sent = spool::submit(&pdf, &job);
let queued = windows_powershell(
"if (Get-PrintJob -Name 'PdfCraft Test Print') { (Get-PrintJob -Name 'PdfCraft Test Print' | Measure-Object).Count } else { 0 }",
&[],
);
let cleaned = windows_powershell("Get-PrintJob -Name 'PdfCraft Test Print' | Remove-PrintJob; Remove-Printer -Name 'PdfCraft Test Print'", &[]);
assert!(sent.is_ok(), "{sent:?}");
assert_eq!(queued.as_deref().map(str::trim), Ok("1"), "the job is in the paused printer's queue: {queued:?}");
assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}");
}
/// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1
/// labels, options the Print dialog sets itself.
const FIERY_PPD: &[u8] = b"*PPD-Adobe: \"4.3\"\n\
-174
View File
@@ -1,174 +0,0 @@
# Print a print-ready PDF on Windows (issue #756). The job arrives in PDFCRAFT_* environment
# variables, never as arguments: printer names contain spaces and quotes, and the document's
# path must not be visible in the process list while it prints.
#
# The in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and
# System.Drawing.Printing spools it with the driver's settings — copies, collation, duplex and
# colour reach the spooler as the job's, the way `lp -o` sends them on CUPS. No PDF reader is
# needed, and the calling code touches no Win32 API.
#
# PDFCRAFT_PRINT_DRYRUN=1 renders every sheet to a PNG in a temporary folder and prints
# nothing: the tests run the whole rendering path on machines without a printer, CI included.
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Runtime.WindowsRuntime
try {
$null = [Windows.Data.Pdf.PdfDocument, Windows.Data.Pdf, ContentType = WindowsRuntime]
} catch {
throw 'this Windows installation cannot render PDFs: the Windows.Data.Pdf component is missing'
}
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]
# Await the WinRT asynchronous APIs from .NET Framework's PowerShell. AsTask is an extension
# method: found by reflecting over the static class — with `.`, an instance call on the type
# object, since `GetMethods` is not static and `::` would not find it.
$type = [System.WindowsRuntimeSystemExtensions]
$opTask = $type.GetMethods() | Where-Object {
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'
} | Select-Object -First 1
$actTask = $type.GetMethods() | Where-Object {
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncAction'
} | Select-Object -First 1
function AwaitOp($operation, $resultType) {
$task = $opTask.MakeGenericMethod($resultType).Invoke($null, @($operation))
$task.Wait()
$task.Result
}
function AwaitAct($action) {
$task = $actTask.Invoke($null, @($action))
$task.Wait()
}
# One page rendered the size it will be drawn (`$drawW` × `$drawH`, hundredths of an inch), at
# `$dpi` dots per inch, aspect-fitted. The caller disposes the bitmap.
function Render-Sheet($page, $drawW, $drawH, $dpi) {
$w = $page.Size.Width
$h = $page.Size.Height
$scale = [Math]::Min($drawW / 100.0 * 96.0 / $w, $drawH / 100.0 * 96.0 / $h)
$options = [Windows.Data.Pdf.PdfPageRenderOptions]::new()
$options.DestinationWidth = [int][Math]::Ceiling($w * $scale * $dpi / 96.0)
$options.DestinationHeight = [int][Math]::Ceiling($h * $scale * $dpi / 96.0)
$stream = [System.IO.MemoryStream]::new()
try {
$random = [System.IO.WindowsRuntimeStreamExtensions]::AsRandomAccessStream($stream)
AwaitAct ($page.RenderToStreamAsync($random, $options))
$stream.Position = 0
# Copy out of the stream: GDI+ reads an Image lazily and needs its stream alive, so the
# render below releases the stream with the function.
$png = [System.Drawing.Image]::FromStream($stream)
try { return [System.Drawing.Bitmap]::new($png) } finally { $png.Dispose() }
} finally {
$stream.Dispose()
}
}
$source = AwaitOp ([Windows.Storage.StorageFile]::GetFileFromPathAsync($env:PDFCRAFT_PRINT_FILE)) ([Windows.Storage.StorageFile])
$document = AwaitOp ([Windows.Data.Pdf.PdfDocument]::LoadFromFileAsync($source)) ([Windows.Data.Pdf.PdfDocument])
if ($document.PageCount -lt 1) { throw 'the print-ready PDF has no pages' }
if ($env:PDFCRAFT_PRINT_DRYRUN -eq '1') {
# Every sheet rendered as the printer would take it, to prove the path without one.
$folder = Join-Path ([System.IO.Path]::GetTempPath()) ('pdfcraft-dryrun-' + $PID)
$null = New-Item -ItemType Directory -Force $folder
for ($i = 0; $i -lt $document.PageCount; $i++) {
$page = $document.GetPage($i)
try {
$bitmap = Render-Sheet $page 750 1000 150
try { $bitmap.Save((Join-Path $folder "sheet-$i.png"), [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() }
} finally {
$page.Dispose()
}
}
Write-Output ('rendered ' + $document.PageCount + ' sheet(s)')
return
}
$printer = [System.Drawing.Printing.PrintDocument]::new()
$printer.DocumentName = $env:PDFCRAFT_PRINT_TITLE
$settings = $printer.PrinterSettings
if ($env:PDFCRAFT_PRINT_PRINTER) {
try {
$settings.PrinterName = $env:PDFCRAFT_PRINT_PRINTER
} catch {
throw ('the printer is not available: ' + $env:PDFCRAFT_PRINT_PRINTER)
}
}
$settings.Copies = [Math]::Min(32767, [Math]::Max(1, [int]$env:PDFCRAFT_PRINT_COPIES))
$settings.Collate = ($env:PDFCRAFT_PRINT_COLLATE -eq '1')
# DEVMODE's names are about the binding edge of a portrait page: Vertical (DMDUP_VERTICAL)
# binds on the long edge, Horizontal (DMDUP_HORIZONTAL) on the short edge.
$settings.Duplex = switch ($env:PDFCRAFT_PRINT_DUPLEX) {
'long-edge' { [System.Drawing.Printing.Duplex]::Vertical }
'short-edge' { [System.Drawing.Printing.Duplex]::Horizontal }
default { [System.Drawing.Printing.Duplex]::Simplex }
}
if ($env:PDFCRAFT_PRINT_GRAYSCALE -eq '1') { $printer.DefaultPageSettings.Color = $false }
# The sheets are already laid out at their final size (CUPS gets `fit-to-page=false`), so each
# one is printed at 100% on paper of its own size and orientation. Before every sheet the driver
# is asked for that paper — the size closest to the sheet's, within an eighth of an inch, turned
# either way — and turned landscape for a wide sheet; a driver without that paper keeps its own.
$paperSizes = @($settings.PaperSizes)
$script:sheets = 0
$printer.add_QueryPageSettings({
param($sender, $e)
if ($script:sheets -ge $document.PageCount) { return }
$page = $document.GetPage($script:sheets)
try {
# Windows.Data.Pdf measures in 1/96 inch, PageSettings in 1/100 inch.
$w = $page.Size.Width / 96.0 * 100.0
$h = $page.Size.Height / 96.0 * 100.0
} finally {
$page.Dispose()
}
$long = [Math]::Max($w, $h)
$short = [Math]::Min($w, $h)
$best = $null
$bestOff = 12.5
foreach ($paper in $paperSizes) {
$off = [Math]::Abs([Math]::Max($paper.Width, $paper.Height) - $long) + [Math]::Abs([Math]::Min($paper.Width, $paper.Height) - $short)
if ($off -lt $bestOff) { $best = $paper; $bestOff = $off }
}
if ($best) { $e.PageSettings.PaperSize = $best }
$e.PageSettings.Landscape = ($w -gt $h)
})
# One sheet at a time: its page is rendered when the driver asks for it, so a long document
# holds only one page's bitmap in memory.
$printer.add_PrintPage({
param($sender, $e)
if ($script:sheets -ge $document.PageCount) { $e.HasMorePages = $false; return }
$page = $document.GetPage($script:sheets)
$bitmap = $null
try {
# Hundredths of an inch throughout. The sheet is drawn at 100%, centred on the whole
# page — not its 1-inch default margins — and shrunk only when the paper is smaller than
# the sheet. The drawing origin is the printable area's corner, so the page's own corner
# is the hard margin back from it; what falls outside the printable area is clipped, as
# on CUPS.
$paper = $e.PageBounds
$w = $page.Size.Width / 96.0 * 100.0
$h = $page.Size.Height / 96.0 * 100.0
$scale = [Math]::Min(1.0, [Math]::Min($paper.Width / $w, $paper.Height / $h))
$drawW = $w * $scale
$drawH = $h * $scale
# The printer's resolution, at most 600 dpi and at most ~64 megapixels a sheet, so a
# poster-sized sheet can't run the machine out of memory.
$dpi = [Math]::Min(600, [Math]::Max(72, $e.Graphics.DpiX))
$dpi = [Math]::Max(36, [Math]::Min($dpi, [Math]::Sqrt(64e6 / ($drawW / 100.0 * $drawH / 100.0))))
$bitmap = Render-Sheet $page $drawW $drawH $dpi
$left = ($paper.Width - $drawW) / 2.0 - $e.PageSettings.HardMarginX
$top = ($paper.Height - $drawH) / 2.0 - $e.PageSettings.HardMarginY
$box = [System.Drawing.RectangleF]::new($left, $top, $drawW, $drawH)
$e.Graphics.DrawImage($bitmap, $box)
} finally {
if ($bitmap) { $bitmap.Dispose() }
$page.Dispose()
}
$script:sheets++
$e.HasMorePages = ($script:sheets -lt $document.PageCount)
})
$printer.Print()
Write-Output ('spooled ' + $document.PageCount + ' sheet(s)')
+116 -2
View File
@@ -740,6 +740,10 @@ const REDRAW_CANDIDATES: usize = 32;
/// Pair tests that the collision sweep may make per glyph of a segment. Ordinary text overlaps each glyph with about one
/// neighbour, so it stays far below this. A segment that needs more is dense, and [`collide`] joins its runs instead.
const COLLIDE_PAIRS_PER_GLYPH: usize = 16;
/// Glyph positions within a fifth of their height are considered the same for redraws.
const REDRAW_POSITION_TOLERANCE: f32 = 0.2;
/// A reset needs three matching glyphs at the start of the run, keeping local kerning from splitting it.
const REDRAW_PREFIX_GLYPHS: usize = 3;
/// Reusable buffers, so a page allocates them once rather than per segment. Glyph placement works in them, one
/// segment at a time, when the segment is emitted.
@@ -818,13 +822,44 @@ fn redraws(glyphs: &[&TextGlyph], a: Run, b: Run) -> bool {
a.len() == b.len()
&& a.range().zip(b.range()).all(|(i, j)| match (glyphs.get(i), glyphs.get(j)) {
(Some(p), Some(g)) => {
let tol = 0.2 * (g.rect[3] - g.rect[1]).max(0.1);
let tol = REDRAW_POSITION_TOLERANCE * (g.rect[3] - g.rect[1]).max(0.1);
p.text == g.text && (p.rect[0] - g.rect[0]).abs() < tol && (p.rect[1] - g.rect[1]).abs() < tol
}
_ => false,
})
}
fn within_position_tolerance(a: f32, b: f32, tolerance: f32) -> bool {
(a - b).abs().partial_cmp(&tolerance) == Some(std::cmp::Ordering::Less)
}
/// Whether `next` restarts `start` after the run has advanced by a glyph height. The fixed prefix
/// bounds this check and requires more than a single backward adjustment to split a run.
fn restarts_run(glyphs: &[&TextGlyph], start: usize, next: usize, progress: f32) -> bool {
let (Some(first), Some(candidate)) = (glyphs.get(start), glyphs.get(next)) else { return false };
let tol = REDRAW_POSITION_TOLERANCE * (candidate.rect[3] - candidate.rect[1]).max(0.1);
if first.text != candidate.text
|| !within_position_tolerance(first.rect[0], candidate.rect[0], tol)
|| !within_position_tolerance(first.rect[1], candidate.rect[1], tol)
{
return false;
}
let h = (first.rect[3] - first.rect[1]).max(0.1);
if !progress.is_finite() || progress <= h {
return false;
}
for offset in 1..REDRAW_PREFIX_GLYPHS {
let (Some(a), Some(b)) = (start.checked_add(offset).and_then(|i| glyphs.get(i)), next.checked_add(offset).and_then(|i| glyphs.get(i))) else {
return false;
};
let tol = REDRAW_POSITION_TOLERANCE * (b.rect[3] - b.rect[1]).max(0.1);
if a.text != b.text || !within_position_tolerance(a.rect[0], b.rect[0], tol) || !within_position_tolerance(a.rect[1], b.rect[1], tol) {
return false;
}
}
true
}
/// Puts a segment's glyphs in visual reading order into `sc.placed`, and its forced word breaks into `sc.breaks`.
///
/// Glyphs are placed by x. Runs that collide, meaning that glyphs of different runs overprint each other, stay whole
@@ -1059,6 +1094,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
// 1. Segments in content order. Every run is a range of consecutive glyphs.
let mut segs: Vec<Seg> = Vec::new();
let mut run_start_x = 0.0;
let mut furthest_progress = 0.0;
for i in 0..n {
let g = glyphs[i].rect;
let cont = segs.last().is_some_and(|s| {
@@ -1067,12 +1104,19 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
let gap = g[0] - glyphs[p].rect[2];
(cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
});
if cont && let Some(s) = segs.last_mut() {
let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress));
if cont
&& !reset
&& let Some(s) = segs.last_mut()
{
s.first.end = i + 1;
union(&mut s.bbox, &g);
s.h = s.h.max(height(i));
furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs());
} else {
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, height(i)));
run_start_x = g[0];
furthest_progress = 0.0;
}
}
// A run that redraws an earlier run at the same place (doubled or shadowed text) is dropped, as a doubled glyph is
@@ -1606,6 +1650,69 @@ mod tests {
extract_page(&text_page(ops), 0, &InterpreterSettings::default()).unwrap().plain_text()
}
#[test]
fn short_doubled_word_at_same_origin_does_not_interleave() {
let ops = "BT /F1 12 Tf 50 Tz 1 0 0 1 100 200 Tm (world) Tj ET\n".repeat(2);
assert_single_word(page_glyphs(&ops));
}
#[test]
fn short_near_shadow_does_not_interleave() {
let ops = "BT /F1 12 Tf 50 Tz 1 0 0 1 100 200 Tm (world) Tj ET\nBT /F1 12 Tf 50 Tz 1 0 0 1 101 200 Tm (world) Tj ET\n";
assert_single_word(page_glyphs(ops));
}
#[test]
fn turned_short_doubled_word_does_not_interleave() {
let one = turned(37.0, 1, 12.0, 100.0, 200.0, "50 Tz (world) Tj");
assert_single_word(page_glyphs(&(one.clone() + &one)));
}
#[test]
fn large_backward_tj_adjustment_stays_in_one_run() {
let text = page_glyphs("BT /F1 12 Tf 50 Tz 1 0 0 1 100 200 Tm [(wor) 3000 (ld)] TJ ET");
assert_eq!(text.plain_text(), "ld wor");
assert_eq!(text.glyphs.iter().map(|g| g.text.as_str()).collect::<Vec<_>>(), ["l", "d", "w", "o", "r"]);
assert!(text.line_of.iter().all(|line| *line == 0));
assert_eq!(text.space_before, [false, false, true, false, false]);
}
#[test]
fn reset_prefix_requires_one_glyph_height_of_progress() {
let mut glyphs = Vec::new();
word(&mut glyphs, "abc", 10.0, 10.0, 6.0);
word(&mut glyphs, "abc", 10.0, 10.0, 6.0);
let glyphs: Vec<&TextGlyph> = glyphs.iter().collect();
assert!(!restarts_run(&glyphs, 0, 3, 9.99));
assert!(!restarts_run(&glyphs, 0, 3, 10.0));
assert!(restarts_run(&glyphs, 0, 3, 10.01));
}
#[test]
fn reset_prefix_uses_the_existing_redraw_position_tolerance() {
let mut near = Vec::new();
word(&mut near, "abc", 10.0, 10.0, 6.0);
word(&mut near, "abc", 11.99, 10.0, 6.0);
let near: Vec<&TextGlyph> = near.iter().collect();
assert!(restarts_run(&near, 0, 3, 10.01));
let mut far = Vec::new();
word(&mut far, "abc", 10.0, 10.0, 6.0);
word(&mut far, "abc", 12.01, 10.0, 6.0);
let far: Vec<&TextGlyph> = far.iter().collect();
assert!(!restarts_run(&far, 0, 3, 10.01));
}
#[test]
fn reset_prefix_rejects_non_finite_positions() {
let mut glyphs = Vec::new();
word(&mut glyphs, "abc", 10.0, 10.0, 6.0);
word(&mut glyphs, "abc", 10.0, 10.0, 6.0);
glyphs[3].rect[0] = f32::NAN;
let glyphs: Vec<&TextGlyph> = glyphs.iter().collect();
assert!(!restarts_run(&glyphs, 0, 3, 10.01));
}
#[test]
fn turned_lines_group_along_their_baseline() {
// Two lines 13 pt apart, words separated by TJ offsets (no space glyphs), the second
@@ -1703,6 +1810,13 @@ mod tests {
extract_page(&text_page(ops), 0, &InterpreterSettings::default()).unwrap()
}
fn assert_single_word(text: PageText) {
assert_eq!(text.plain_text(), "world");
assert_eq!(text.glyphs.iter().map(|g| g.text.as_str()).collect::<String>(), "world");
assert_eq!(text.line_of, [0; 5]);
assert_eq!(text.space_before, [false; 5]);
}
#[test]
fn runs_printed_over_each_other_stay_whole_at_any_angle() {
// Two runs that start at one point (exact overprint), that start 40 pt along and 3 pt down
+89
View File
@@ -2995,6 +2995,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.viewport_screen = out.inner_rect;
view.auto_scroll.paint(ui, avail);
if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) {
selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden);
}
// Collect all main-page demand, including cached surfaces, for byte admission later.
// Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding
@@ -3263,6 +3266,92 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
quick_bar(app, avail, ui);
}
#[derive(Clone, Copy)]
enum SelectionAction {
Note,
Markup(comments::CommentTool),
Replace,
Copy,
}
/// Actions on an existing text selection, next to the selected words.
fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info: &DocInfo, prefs: &comments::CommentPrefs, can_comment: bool) {
if ui.input(|input| input.pointer.is_decidedly_dragging()) {
return;
}
let Some(selection) = view.selection else { return };
let Some(text) = view.texts.get(&selection.page) else { return };
let Some(xf) = view.page_xform(selection.page) else { return };
let Some(last) = selection.rects(text).last().copied() else { return };
let selected = selection.text(text);
if selected.is_empty() {
return;
}
let rect = xf.view_rect(last);
if !viewport.intersects(rect) {
return;
}
let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0));
let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) };
let mut chosen = None;
egui::Area::new(egui::Id::new(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
for (label, action) in [
(tl!("Add comment"), SelectionAction::Note),
(tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)),
(tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)),
(tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)),
(tl!("Replace text"), SelectionAction::Replace),
] {
if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() {
chosen = Some(action);
}
}
if ui.button(tl!("Copy")).clicked() {
chosen = Some(SelectionAction::Copy);
}
});
});
});
let Some(action) = chosen else { return };
if let SelectionAction::Copy = action {
ui.ctx().copy_text(selected);
return;
}
let Some((page, quads)) = view.selection_quads(info) else { return };
let last = quads.last().copied().unwrap_or_default();
let at = [last[2].max(last[6]), last[1].max(last[3])];
view.clear_selection();
match action {
SelectionAction::Note | SelectionAction::Replace => {
if matches!(action, SelectionAction::Replace) {
view.comments.replace = Some(quads);
}
view.comments.composer = Some(comments::Composer {
page,
at,
kind: if matches!(action, SelectionAction::Replace) { comments::ComposerKind::Replace } else { comments::ComposerKind::Note },
text: String::new(),
focus: true,
});
}
SelectionAction::Markup(tool) => {
// Every markup button carries a markup tool; anything else adds nothing.
let Some(kind) = tool.markup() else { return };
view.pending_edit = Some(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page,
shape: pdfcraft_engine::Shape::TextMarkup { kind, quads },
style: prefs.style(tool),
contents: String::new(),
author: prefs.author.clone(),
}));
}
// Copied above.
SelectionAction::Copy => {}
}
}
/// Run a push button's action (the ones that need no JavaScript engine).
fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_engine::form_scripts::ButtonAction) {
use pdfcraft_engine::form_scripts::ButtonAction as B;
+2
View File
@@ -2277,3 +2277,5 @@ smooth scrolling Off معطّل
Black and white أبيض وأسود
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… لا يقبل برنامج تشغيل هذه الطابعة إعدادات الطباعة على الوجهين من البرامج الأخرى، لذا يُطبَّق إعداده الخاص. غيّره من خصائص…
Tools panel side جانب لوحة الأدوات
Add comment إضافة تعليق
Change tag properties تغيير خصائص الوسم
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2219,3 +2219,5 @@ smooth scrolling Off Изключено
Black and white Черно-бяло
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйверът на този принтер не приема настройки за двустранен печат от други програми и прилага собствената си настройка. Променете я от Свойства…
Tools panel side Страна на панела с инструменти
Add comment Добавяне на коментар
Change tag properties Промяна на свойствата на етикета
Can't render this file because it contains an unexpected character in line 8 and column 16.
+2
View File
@@ -2333,3 +2333,5 @@ smooth scrolling Off Vypnuto
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Písmo tohoto pole neumí zobrazit „{c}“ ({code}), proto PdfCraft místo něj ukazuje ?. Hodnota se uloží a jiné prohlížeče PDF pole podle ní překreslí.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Písma, kterými PdfCraft píše, neumí zobrazit „{c}“ ({code}), proto se ve všech prohlížečích PDF zobrazí jako ?. Text se uloží.
Tools panel side Strana panelu nástrojů
Add comment Přidat komentář
Change tag properties Změnit vlastnosti značky
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2257,3 +2257,5 @@ smooth scrolling Off Aus
Black and white Schwarzweiß
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Der Treiber dieses Druckers übernimmt keine Einstellungen für beidseitigen Druck von anderen Programmen, daher gilt seine eigene Einstellung. Ändern unter Eigenschaften…
Tools panel side Seite des Werkzeugbereichs
Add comment Kommentar hinzufügen
Change tag properties Tag-Eigenschaften ändern
Can't render this file because it contains an unexpected character in line 6 and column 51.
+2
View File
@@ -2275,3 +2275,5 @@ smooth scrolling Off Ανενεργή
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Η γραμματοσειρά αυτού του πεδίου δεν μπορεί να σχεδιάσει το «{c}» ({code}), γι' αυτό το PdfCraft δείχνει ? στη θέση του. Η τιμή αποθηκεύεται και άλλα προγράμματα προβολής PDF επανασχεδιάζουν το πεδίο από αυτήν.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Οι γραμματοσειρές με τις οποίες γράφει το PdfCraft δεν μπορούν να σχεδιάσουν το «{c}» ({code}), γι' αυτό εμφανίζεται ως ? σε κάθε πρόγραμμα προβολής PDF. Το κείμενο αποθηκεύεται.
Tools panel side Πλευρά πλαισίου εργαλείων
Add comment Προσθήκη σχολίου
Change tag properties Αλλαγή ιδιοτήτων ετικέτας
Can't render this file because it contains an unexpected character in line 477 and column 12.
+91 -7
View File
@@ -85,6 +85,8 @@
Display theme Tema de visualización
Side panels Paneles laterales
Enable Acrobat JavaScript Activar Acrobat JavaScript
Flatten Fill & Sign when saving Acoplar Rellenar y firmar al guardar
Text, marks and signatures become part of the page. Other comments stay editable. El texto, las marcas y las firmas pasan a formar parte de la página. Los demás comentarios siguen siendo editables.
OK Aceptar
Join our Discord Únete a nuestro Discord
Close Cerrar
@@ -656,7 +658,7 @@
Social Security Number Número de la Seguridad Social
Arbitrary Mask Máscara arbitraria
Mask: Máscara:
9 digit, A letter, O letter or digit, X any character 9 dígito, A letra, O letra o dígito, X cualquier carácter
9 digit, A letter, O letter or digit, X any character 9: dígito; A: letra; O: letra o dígito; X: cualquier carácter
Example: {sample} Ejemplo: {sample}
Field value is not validated El valor del campo no se valida
Field value is in range: El valor del campo está en el intervalo:
@@ -785,6 +787,7 @@
Rotation Rotación
Appear behind page Mostrar detrás de la página
Appear on top of page Mostrar encima de la página
Offset from centre Desplazamiento desde el centro
From colour A partir de un color
Page Range Options Opciones de intervalo de páginas
Preview: page {p} of {n} Vista previa: página {p} de {n}
@@ -923,6 +926,7 @@
Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Quita la información oculta, los metadatos, los scripts y el contenido incrustado, y reescribe los archivos.
Type text Escribe texto
Text to insert Texto que insertar
Inserted text Texto insertado
Replacement text Texto de reemplazo
Edit text… Editar texto…
Add a sticky note here Añadir una nota adhesiva aquí
@@ -933,6 +937,7 @@
This comment's appearance can't be changed yet. El aspecto de este comentario todavía no se puede cambiar.
Icon Icono
Thickness Grosor
Line ending Extremo de línea
Modified Fecha de modificación
No status has been set. No se ha establecido ningún estado.
Compare Files Comparar archivos
@@ -1226,7 +1231,7 @@
1 mark 1 marca
{n} marks {n} marcas
Dynamic Dinámicos
Sign Here Firme aquí
Sign Here Firma aquí
Standard Business Empresariales estándar
Choose a stamp, then click on the page to place it. Elige un sello y haz clic en la página para colocarlo.
Note Nota
@@ -1584,6 +1589,7 @@
Looking for printers… Buscando impresoras…
Sending to {printer}… Enviando a {printer}…
Choose a folder for the searchable files Elige una carpeta para los archivos con búsqueda
Files are combined in this order. Leave Pages empty to take every page. Los archivos se combinan en este orden. Deja Páginas vacío para incluir todas las páginas.
Files are combined from top to bottom. Leave Pages empty to take every page. Los archivos se combinan de arriba abajo. Deja Páginas vacío para incluir todas las páginas.
Unlock… Desbloquear…
Select a password-protected file first Selecciona primero un archivo protegido con contraseña
@@ -1610,6 +1616,11 @@
Add folder and subfolders Añadir carpeta y subcarpetas
No PDFs, images or text files found in {folder} No se encontraron PDF, imágenes ni archivos de texto en {folder}
Added the first {n} files in {folder} Se añadieron los primeros {n} archivos de {folder}
comment menu Edit Editar
Undo {action} Deshacer {action}
Redo {action} Rehacer {action}
certificate Subject Titular
action wizard Start Iniciar
Click to sort, drag to move the column. Drag its edge to resize it. Haz clic para ordenar; arrastra para mover la columna. Arrastra su borde para cambiar el ancho.
Reset columns Restablecer columnas
Remove {n} files Quitar {n} archivos
@@ -2031,7 +2042,73 @@
Default page display Presentación de página predeterminada
Default zoom Zoom predeterminado
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página.
Show Mostrar
Sort Ordenar
Reverse the order Invertir el orden
Grab bag Miscelánea
Table Tabla
Username Nombre de usuario
Display name Nombre para mostrar
Real name Nombre real
Name (A–Z) Nombre (A–Z)
Merged PRs PR fusionados
PRs PR
Commits Commits
Lines added Líneas añadidas
+LOC +LOC
Lines deleted Líneas eliminadas
−LOC −LOC
Line delta Diferencia de líneas
ΔLOC ΔLOC
Binary assets added Recursos binarios añadidos
+Bin +Bin
Binary assets removed Recursos binarios eliminados
−Bin −Bin
First commit Primer commit
First Primero
Last commit Último commit
Last Último
No contributor data was built into this copy. Esta copia no incluye datos de colaboradores.
No model credits were built into this copy. Esta copia no incluye créditos de modelos.
{n} contributors · {c} commits {n} colaboradores · {c} commits
Company Empresa
Model Modelo
% of all commits % de todos los commits
Lines +/− Líneas +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}
Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez
Add {what} Añadir {what}
Use {what} Usar {what}
Place saved {what} Colocar {what} desde lo guardado
Remove saved {what} Quitar {what} de lo guardado
A link Un enlace
A button Un botón
A script Un script
{who} in this document tried to open an address PdfCraft won't open: {e}. {who} de este documento intentó abrir una dirección que PdfCraft no abre: {e}.
{who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. {who} de este documento intentó abrir una dirección que PdfCraft no abre: {e}. Desde los documentos solo se abren enlaces web (http, https) y de correo (mailto).
Another file dialog is still open. Finish with it first. Todavía hay otro cuadro de archivos abierto. Termina primero con ese.
Couldn't show the file picker. Please try again. No se pudo mostrar el selector de archivos. Inténtalo de nuevo.
The document changed while you were choosing a file, so nothing was changed. El documento cambió mientras elegías un archivo, así que no se modificó nada.
The document was closed before it could be saved. El documento se cerró antes de poder guardarlo.
The pages were extracted but not deleted, because the document changed while you were choosing a folder. Las páginas se extrajeron pero no se eliminaron, porque el documento cambió mientras elegías una carpeta.
That didn't work: an internal error stopped it ({m}). No ha funcionado: un error interno lo ha detenido ({m}).
The Windows store's digital IDs couldn't be listed: {e} No se pudieron listar los ID digitales del almacén de Windows: {e}
Align fields Alinear campos
Distribute fields Distribuir campos
Match field sizes Igualar el tamaño de los campos
Mark for redaction Marcar para censurar
Mark pages for redaction Marcar páginas para censurar
Change document properties Cambiar propiedades del documento
Mark differences Marcar diferencias
Run form script Ejecutar script del formulario
Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. El contenido con scripts debe poder usarse con teclado y con tecnologías de asistencia. Revisa los scripts del documento.
Table rows belong directly in a table or its head, body or foot. Fix the table's tags. Las filas de una tabla van directamente en la tabla o en su encabezado, cuerpo o pie. Corrige las etiquetas de la tabla.
Basic constraints Restricciones básicas
SHA-1 digest Huella SHA-1
SHA-256 digest Huella SHA-256
Squiggly Ondulado
Home / End Inicio / Fin
H / Space (hold) H / Espacio (mantener)
Reload documents when their files change on disk Recargar los documentos cuando sus archivos cambien en el disco
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Este archivo ha cambiado en el disco. Recárgalo para ver la nueva versión; se perderán tus cambios sin guardar.
Reload Recargar
@@ -2049,6 +2126,11 @@
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elige una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia.
Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e}
Go to page… Ir a la página…
About Acerca de
Contributors Colaboradores
Models Modelos
Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Continúa solo si confías en este documento. Una dirección puede llevar información del documento, como lo que escribiste en su formulario.
Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Imprime a una cara. Mantén las hojas en orden, corta por las marcas y apila los montones de izquierda a derecha y de arriba abajo.
For page sizes and margins. Para tamaños de página y márgenes.
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Las fuentes de texto PDF no pueden mostrar «{c}» ({code}). Elija texto compatible.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Las fuentes estándar no pueden mostrar «{c}» ({code}). Por ahora solo se pueden añadir como texto caracteres de Europa occidental.
@@ -2237,10 +2319,8 @@
Change Author Cambiar el autor
Change Subject Cambiar el asunto
Change Keywords Cambiar las palabras clave
Run form script Ejecutar el script del formulario
Save as {level} Guardar como {level}
Create links Crear enlaces
Mark differences Marcar diferencias
# Multi-object selection and movement (#844)
@plural {n} object selected|{n} objects selected {n} objeto seleccionado|{n} objetos seleccionados
@@ -2288,8 +2368,8 @@ key Space Espacio
Tools Herramientas
Inspector Inspector
Select page {label} Seleccionar la página {label}
Rotate page {label} counterclockwise Girar la página {label} en sentido antihorario
Rotate page {label} clockwise Girar la página {label} en sentido horario
Rotate page {label} counterclockwise Girar la página {label} a la izquierda
Rotate page {label} clockwise Girar la página {label} a la derecha
Delete page {label} Eliminar la página {label}
Mouse wheel scroll speed Velocidad de desplazamiento de la rueda del ratón
scroll speed Slow Lenta
@@ -2299,11 +2379,15 @@ scroll speed Fast Rápida
smooth scrolling On Activado
smooth scrolling Off Desactivado
Enhance camera image Mejorar foto de cámara
Drag the corners onto the document's edges, then choose Enhance page. Arrastre las esquinas hasta los bordes del documento y elija Mejorar página.
Drag the corners onto the document's edges, then choose Enhance page. Arrastra las esquinas hasta los bordes del documento y elige Mejorar página.
Enhance page Mejorar página
Certificate authority Autoridad de certificación
End entity Entidad final
Clean-up Limpieza
Original Original
Greyscale Escala de grises
Black and white Blanco y negro
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… El controlador de esta impresora no acepta los ajustes de doble cara de otros programas, así que se aplica su propio ajuste. Cámbielo en Propiedades…
Tools panel side Lado del panel de herramientas
Add comment Añadir comentario
Change tag properties Cambiar propiedades de la etiqueta
Can't render this file because it contains an unexpected character in line 6 and column 60.
+2
View File
@@ -2256,3 +2256,5 @@ smooth scrolling Off Désactivé
Black and white Noir et blanc
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Le pilote de cette imprimante n'accepte pas les réglages recto verso des autres programmes ; son propre réglage s'applique. Modifiez-le dans Propriétés…
Tools panel side Côté du panneau Outils
Add comment Ajouter un commentaire
Change tag properties Modifier les propriétés de la balise
Can't render this file because it contains an unexpected character in line 6 and column 39.
+2
View File
@@ -2255,3 +2255,5 @@ smooth scrolling Off Ki
Black and white Fekete-fehér
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… A nyomtató illesztőprogramja nem fogadja el más programoktól a kétoldalas nyomtatás beállítását, ezért a saját beállítása érvényes. Módosítás: Tulajdonságok…
Tools panel side Az Eszközök panel oldala
Add comment Megjegyzés hozzáadása
Change tag properties Címke tulajdonságainak módosítása
Can't render this file because it contains an unexpected character in line 7 and column 44.
+2
View File
@@ -2265,3 +2265,5 @@ smooth scrolling Off Disattivato
Black and white Bianco e nero
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Il driver di questa stampante non accetta le impostazioni fronte-retro da altri programmi, quindi vale la sua impostazione. Modificala in Proprietà…
Tools panel side Lato del pannello Strumenti
Add comment Aggiungi commento
Change tag properties Modifica proprietà del tag
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2256,3 +2256,5 @@ smooth scrolling Off オフ
Black and white 白黒
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… このプリンターのドライバーは他のプログラムからの両面印刷の設定を受け付けないため、ドライバー自身の設定が使われます。変更先: プロパティ…
Tools panel side ツールパネルの位置
Add comment コメントを追加
Change tag properties タグのプロパティを変更
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2257,3 +2257,5 @@ smooth scrolling Off Өшірулі
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Бұл өрістің қаріпі «{c}» ({code}) таңбасын сала алмайды, сондықтан PdfCraft оның орнына ? көрсетеді. Мән сақталады, ал басқа PDF қарау құралдары өрісті соған қарап қайта салады.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft жазатын қаріптер «{c}» ({code}) таңбасын сала алмайды, сондықтан ол барлық PDF қарау құралдарында ? болып көрінеді. Мәтін сақталады.
Tools panel side Құралдар панелінің жағы
Add comment Түсініктеме қосу
Change tag properties Тег қасиеттерін өзгерту
Can't render this file because it contains an unexpected character in line 813 and column 12.
+2
View File
@@ -2239,3 +2239,5 @@ smooth scrolling Off Wyłączone
Black and white Czarno-biały
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Sterownik tej drukarki nie przyjmuje ustawień druku dwustronnego z innych programów, więc obowiązuje jego własne ustawienie. Zmień je w oknie Właściwości…
Tools panel side Strona panelu narzędzi
Add comment Dodaj komentarz
Change tag properties Zmień właściwości znacznika
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2270,3 +2270,5 @@ smooth scrolling Off Desativada
Black and white Preto e branco
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… O driver desta impressora não aceita configurações de frente e verso de outros programas, então vale a configuração dele. Altere em Propriedades…
Tools panel side Lado do painel Ferramentas
Add comment Adicionar comentário
Change tag properties Alterar propriedades da tag
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2169,3 +2169,5 @@ smooth scrolling Off Выкл
Black and white Чёрно-белый
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер этого принтера не принимает настройки двусторонней печати от других программ, поэтому действует его собственная настройка. Измените её в окне Свойства…
Tools panel side Сторона панели инструментов
Add comment Добавить комментарий
Change tag properties Изменить свойства тега
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2264,3 +2264,5 @@ smooth scrolling Off Izklopljeno
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Pisava tega polja ne more prikazati »{c}« ({code}), zato PdfCraft namesto njega prikaže ?. Vrednost se shrani, drugi pregledovalniki PDF pa polje znova narišejo iz nje.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Pisave, s katerimi piše PdfCraft, ne morejo prikazati »{c}« ({code}), zato se v vseh pregledovalnikih PDF prikaže kot ?. Besedilo se shrani.
Tools panel side Stran podokna z orodji
Add comment Dodaj komentar
Change tag properties Spremeni lastnosti oznake
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2172,3 +2172,5 @@ smooth scrolling Off ఆఫ్
Greyscale గ్రేస్కేల్
Black and white నలుపు తెలుపు
Tools panel side టూల్స్ ప్యానెల్ వైపు
Add comment వ్యాఖ్యను జోడించు
Change tag properties ట్యాగ్ లక్షణాలను మార్చు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+2
View File
@@ -2255,3 +2255,5 @@ smooth scrolling Off Kapalı
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Bu alanın yazı tipi “{c}” ({code}) karakterini çizemiyor, bu yüzden PdfCraft yerine ? gösteriyor. Değer kaydedilir ve diğer PDF görüntüleyiciler alanı ondan yeniden çizer.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft'ın yazdığı yazı tipleri “{c}” ({code}) karakterini çizemiyor, bu yüzden her PDF görüntüleyicide ? olarak görünür. Metin kaydedilir.
Tools panel side Araçlar panelinin yeri
Add comment Yorum ekle
Change tag properties Etiket özelliklerini değiştir
Can't render this file because it contains an unexpected character in line 811 and column 12.
+2
View File
@@ -2278,3 +2278,5 @@ smooth scrolling Off Вимкнено
Black and white Чорно-білий
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер цього принтера не приймає налаштування двобічного друку від інших програм, тому діє його власне налаштування. Змініть його у вікні Властивості…
Tools panel side Бік панелі інструментів
Add comment Додати коментар
Change tag properties Змінити властивості тегу
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2220,3 +2220,5 @@ smooth scrolling Off 关
Black and white 黑白
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此打印机的驱动程序不接受其他程序的双面打印设置,因此使用其自身设置。更改位置:属性…
Tools panel side 工具面板位置
Add comment 添加注释
Change tag properties 更改标签属性
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2133,3 +2133,5 @@ smooth scrolling Off 關
Black and white 黑白
This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此印表機的驅動程式不接受其他程式的雙面列印設定,因此使用其自身設定。變更位置:屬性…
Tools panel side 工具面板位置
Add comment 新增註解
Change tag properties 變更標籤屬性
Can't render this file because it contains an unexpected character in line 9 and column 16.
+11 -1
View File
@@ -164,7 +164,8 @@ impl PrintDraft {
printer: self.printer.clone(),
copies: self.copies.max(1),
collate: self.collate,
duplex: self.duplex,
duplex: Some(self.duplex),
scaling: spool::Scaling::Exact,
grayscale: self.grayscale,
title: title.to_string(),
// Only for the printer the options were read from.
@@ -903,6 +904,15 @@ pub(crate) fn body(
mod tests {
use super::*;
#[test]
fn composed_sheets_keep_exact_scaling_and_the_dialogs_duplex_choice() {
let draft = PrintDraft { duplex: spool::Duplex::LongEdge, ..PrintDraft::default() };
let job = draft.job("report.pdf");
assert_eq!(job.scaling, spool::Scaling::Exact);
assert_eq!(job.duplex, Some(spool::Duplex::LongEdge));
assert_eq!(PrintDraft::default().job("report.pdf").duplex, Some(spool::Duplex::Off));
}
#[test]
fn print_preview_rasters_stay_within_the_texture_limit() {
// Actual size on a 14,400 pt (200 in) page would ask for a raster far past any texture.
+38
View File
@@ -151,6 +151,44 @@ fn a_selection_made_first_is_marked_when_a_tool_is_picked() {
assert!(h.state().views[0].selected_text().is_none(), "the selection is consumed");
}
#[test]
fn selected_text_shows_actions_and_underlines_without_changing_tools() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
h.run_steps(3);
h.get_by_label("Add comment");
h.get_by_label("Replace text");
h.get_by_label("Underline").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!(c[0].subtype, "Underline");
assert!(h.state().views[0].selected_text().is_none());
assert_eq!(h.state().quick_tool, QuickTool::Select);
}
#[test]
fn selected_text_replace_action_opens_replacement_composer() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
h.run_steps(3);
h.get_by_label("Replace text").click();
h.run_steps(2);
h.get_by_label("Replacement text");
assert!(h.state().views[0].selected_text().is_none());
}
#[test]
fn selected_text_comment_action_opens_note_composer() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
h.run_steps(3);
h.get_by_label("Add comment").click();
h.run_steps(2);
assert!(matches!(h.state().views[0].comments.composer.as_ref().map(|c| &c.kind), Some(pdfcraft_ui_egui::comments::ComposerKind::Note)));
assert!(h.state().views[0].selected_text().is_none());
}
#[test]
fn shapes_and_ink_are_drawn_by_dragging() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
@@ -94,6 +94,7 @@ fn shared_properties_preserve_mixed_values_and_undo_the_whole_change() {
h.run_steps(4);
h.get_by_label("Line Thickness:").click();
h.run_steps(4);
assert!(h.state().bulk_field_props.as_ref().unwrap().width.apply, "thickness click enables the property");
h.state_mut().bulk_field_props.as_mut().unwrap().width.value = 4.0;
h.get_by_label("OK").click();
h.run_steps(4);
+1 -1
View File
@@ -1,6 +1,6 @@
# PdfCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first public architecture document, written from the code on main) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
How PdfCraft is built today. The design rationale and the planned crates live in
`plan/architecture.md` and `plan/adr/` (local-only); this file describes what is on `main`. Each
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Each hardware feature Acrobat Pro uses, per platform, ours against Acrobat's. Acrobat runs on macOS
and Windows (plus a limited web viewer/editor in Document Cloud); PdfCraft runs on macOS, Windows,
@@ -17,7 +17,7 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30
| Multi-threaded rendering | yes | yes (render pool, tiles, watchdog) | yes | yes | single thread | `view.render-priority` |
| HiDPI / Retina | yes | yes | yes, per-monitor DPI v2 (#324, #725) | yes | yes | text blurry at 1080p/1440p (#730) |
| Multiple monitors | yes, multiple windows | one window | one window; surfaces fit the GPU limit (#577) | one window | n/a | `view.multiple-windows` planned |
| Printers | all, PostScript, printer properties | CUPS spooler | Windows spooler: lists printers, prints rasterised at the printer's resolution (#756, PR #847) | CUPS spooler | **none** | `crates/print/README.md` |
| Printers | all, PostScript, printer properties | CUPS via craft-print | Windows PDF raster spooler via craft-print (#756, PR #847) | CUPS via craft-print | **none** | `crates/print/README.md` |
| Scanners (TWAIN, WIA, ICA) | yes | none | none | none (SANE) | n/a | `ocr.scanner-acquire` planned |
| Smart cards and USB tokens | yes (PKCS #11, CryptoTokenKit, CNG) | Keychain identities (software keys) | CNG store keys, with PIN prompts for cards (#742) | none (PKCS #11 planned) | none | `sign.pkcs11` planned |
| Pen and touch | Touch mode, pen ink, Windows Ink | trackpad pinch zoom; ink from pointer, no pressure | ink from pointer, no pressure or touch mode | as macOS | pointer | touchpad scrolling 1:1 (#759, PR #868) |
@@ -42,4 +42,5 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30
|---|---|---|
| 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues |
| 2026-10-10 | minor | Windows printing shipped (#756): the Windows row's gap is closed; the web row keeps "printing" |
| 2026-10-11 | minor | Shared craft-print spooler; Windows preserves PDF sheet size/orientation and uses corrected duplex binding edges. Physical output remains a hardware check. |
| 2026-10-11 | minor | The Printers row's Windows cell still said none: now the Windows spooler (#756, PR #847). Touchpad scrolling 1:1 (#759, PR #868) |
+3 -1
View File
@@ -1,5 +1,6 @@
# PdfCraft roadmap: milestones and what's next
> **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; earlier: M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **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; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -75,7 +76,7 @@ total matches the parity estimate.
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Smooth scrolling (Use system setting, On, Off) decides the wheel's easing on every platform. |
@@ -139,6 +140,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. |
| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed: wheel scrolling and zooming without easing, egui's animations off. Preferences ▸ Smooth scrolling (Use system setting, On, Off) overrides it for the wheel. macOS, Linux and the browser aren't asked yet. Estimates unchanged. |
| 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. |
| 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. |
+13 -13
View File
@@ -1490,8 +1490,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/ui_group/text_select.rs::shift_click_extends_the_selection_from_its_anchor"]
notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page)."
evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/ui_group/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/comments.rs::selected_text_comment_action_opens_note_composer"]
notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection. The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar."
[[feature]]
id = "view.column-select"
@@ -7890,8 +7890,8 @@ milestone = "M10"
status = "shipped"
commands = ["print.dialog"]
tools = ["printers", "printer_options", "doc_print"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::windows_lists_the_printers_the_system_knows", "crates/print/src/tests.rs::the_print_script_renders_the_job_and_reaches_the_spooler", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options"]
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows the queues and the default come from Win32_Printer (PowerShell CIM) and jobs are rendered with Windows.Data.Pdf and spooled with the driver's own settings, each sheet at 100% on the driver's paper closest to its size (landscape for a wide sheet; shrunk only when no paper is large enough), rasterised at the printer's resolution up to 600 dpi and about 64 megapixels a sheet, not sent as vectors; the printer list and the job run in the background, without a console window; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job"
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked"]
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows the queues and the default come from Win32_Printer (PowerShell CIM) and jobs are rendered with Windows.Data.Pdf and spooled through shared craft-print at each sheet's physical size and orientation, with bounded raster resolution up to 600 dpi (not vector passthrough); the printer list and submission run on workers without a console window. Native Windows regression execution is pending; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job"
[[feature]]
id = "print.copies-collate"
@@ -7902,7 +7902,7 @@ milestone = "M10"
status = "shipped"
commands = ["print.dialog"]
tools = ["doc_print"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script", "crates/print/src/tests.rs::the_print_script_renders_the_job_and_reaches_the_spooler"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
notes = "lp -n, collate option; on Windows the driver's Copies and Collate settings, as the job's"
[[feature]]
@@ -7962,7 +7962,7 @@ milestone = "M10"
status = "shipped"
commands = ["print.dialog"]
tools = ["doc_print"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script", "crates/print/src/tests.rs::the_print_script_renders_the_job_and_reaches_the_spooler"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting"
[[feature]]
@@ -8058,7 +8058,7 @@ milestone = "M10"
status = "partial"
commands = ["print.dialog"]
tools = ["doc_print"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); the PDF itself stays in colour"
[[feature]]
@@ -8374,8 +8374,8 @@ milestone = "M12"
status = "shipped"
commands = ["a11y.check"]
tools = ["accessibility_check"]
evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
notes = "full check: options dialog (report, page range, 32 rules by category, 31 on by default), results panel by category with Passed / Failed / Needs manual check / Skipped and findings that go to their page"
evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/a11y/src/tests.rs::structure_findings_identify_indirect_elements_without_guessing_inline_objects", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
notes = "full check: options dialog (report, page range, 32 rules by category, 31 on by default), results panel by category with Passed / Failed / Needs manual check / Skipped and findings that go to their page. Structure findings expose the indirect tag object number and generation through accessibility_check, when available, for precise follow-up in the Tags Panel."
[[feature]]
id = "a11y.checker-report"
@@ -8839,11 +8839,11 @@ title = "Tag properties (type, title, actual text, alternate text, language)"
area = "M"
tier = "P1"
milestone = "M12"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["accessibility_tags", "accessibility_set_tag_property"]
evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"]
notes = "The engine and headless tools list reachable indirect tags and edit type, title, actual text, alternate text and language with undo. An on-screen tag properties editor and inline tag dictionary support remain planned with the Tags Panel (#982)."
[[feature]]
id = "a11y.role-map"
+1 -1
View File
File diff suppressed because one or more lines are too long
+76 -8
View File
@@ -142,14 +142,14 @@ impl XConnection {
dpi_override
},
EnvVarDPI::NotSet => {
if let Some(dpi) = self.get_xft_dpi() {
dpi / 96.
} else {
calc_dpi_factor(
(crtc.width.into(), crtc.height.into()),
(output_info.mm_width as _, output_info.mm_height as _),
)
}
let screen = self.default_root();
default_dpi_factor(
self.get_xft_dpi(),
(screen.width_in_pixels.into(), screen.height_in_pixels.into()),
(screen.width_in_millimeters.into(), screen.height_in_millimeters.into()),
(crtc.width.into(), crtc.height.into()),
(output_info.mm_width as _, output_info.mm_height as _),
)
},
};
@@ -184,3 +184,71 @@ impl XConnection {
Ok(self.xcb_connection().randr_get_crtc_info(crtc_id, x11rb::CURRENT_TIME)?.reply()?.mode)
}
}
// PdfCraft patch: without Xft DPI, use the X server's configured screen DPI before
// the output's physical density. Xorg can deliberately run a dense panel at 96 DPI;
// using its EDID dimensions instead makes only winit applications appear zoomed.
// Keep configured HiDPI screens and the explicit `randr` override working.
fn default_dpi_factor(
xft_dpi: Option<f64>,
screen_pixels: (u32, u32),
screen_mm: (u64, u64),
output_pixels: (u32, u32),
output_mm: (u64, u64),
) -> f64 {
if let Some(dpi) = xft_dpi {
return dpi / 96.;
}
if screen_pixels.0 != 0 && screen_pixels.1 != 0 && screen_mm.0 != 0 && screen_mm.1 != 0 {
calc_dpi_factor(screen_pixels, screen_mm)
} else {
calc_dpi_factor(output_pixels, output_mm)
}
}
#[cfg(test)]
mod tests {
use super::{calc_dpi_factor, default_dpi_factor};
const PIXELS: (u32, u32) = (1920, 1080);
const SCREEN_96: (u64, u64) = (508, 285);
const SCREEN_144: (u64, u64) = (339, 190);
const SCREEN_192: (u64, u64) = (254, 143);
const PANEL: (u64, u64) = (381, 214);
#[test]
fn server_dpi_wins_over_the_panels_physical_density() {
assert_eq!(default_dpi_factor(None, PIXELS, SCREEN_96, PIXELS, PANEL), 1.0);
assert_eq!(default_dpi_factor(None, PIXELS, SCREEN_144, PIXELS, PANEL), 1.5);
assert_eq!(default_dpi_factor(None, PIXELS, SCREEN_192, PIXELS, PANEL), 2.0);
}
#[test]
fn xft_dpi_still_has_priority() {
for dpi in [96.0, 120.0, 144.0, 192.0] {
assert_eq!(
default_dpi_factor(Some(dpi), PIXELS, SCREEN_192, PIXELS, PANEL),
dpi / 96.0,
);
}
}
#[test]
fn missing_screen_dimensions_keep_the_randr_fallback() {
let physical_scale = calc_dpi_factor(PIXELS, PANEL);
assert!(physical_scale > 1.0);
for screen_mm in [(0, 285), (508, 0), (0, 0)] {
assert_eq!(default_dpi_factor(None, PIXELS, screen_mm, PIXELS, PANEL), physical_scale);
}
for screen_pixels in [(0, 1080), (1920, 0), (0, 0)] {
assert_eq!(default_dpi_factor(None, screen_pixels, SCREEN_96, PIXELS, PANEL), physical_scale);
}
assert_eq!(default_dpi_factor(None, PIXELS, (0, 0), PIXELS, (0, 0)), 1.0);
}
#[test]
fn explicit_randr_calculation_still_uses_output_dimensions() {
assert_eq!(calc_dpi_factor(PIXELS, PANEL), 4.0 / 3.0);
assert_eq!(calc_dpi_factor(PIXELS, SCREEN_192), 2.0);
}
}