Compare commits

...

5 Commits

Author SHA1 Message Date
Sir Clangington III. dc4d86ba40
fix(automation): sync staged output before publication
Sync staged output after writing all bytes and before replacing its destination, closing the handle before rename. Return sync failures while keeping the old destination and ordinary save state available for retry.

Fixes #1804
2026-10-13 21:44:45 -06:00
Ibn Zwanckaert 5e12f5fd6d
feat(ui): reorder and cycle document tabs
Reorder document tabs by dragging while keeping the active document and its view state. Add Ctrl+Tab and Ctrl+Shift+Tab cycling in visible order, including Combine files, and translate the menu labels.

Keep timestamped batch test expectations tied to the originally generated PDF bytes.

Closes #1537
Closes #1542

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:20 -06:00
Tanish Manku d9607ec3be F3: one documented substitution chain, and report what it chose and why
A font the document does not embed was replaced by whatever the caller
happened to pick, and nothing told the user. `doc_info` said only
`embedded: false`, which is why "my Chinese PDF shows nothing" has been
a mystery rather than a sentence.

`crates/fonts/src/fallback.rs` applies one chain, in a documented order,
and returns what it chose with a reason:

1. embedded -> nothing substituted;
2. /BaseFont names a standard-14 face or a known alias (exact metrics);
3. craft-fonts holds that exact family;
4. /CIDSystemInfo /Ordering names a script we have a face for;
5. the ordering names a script we have no licensed face for -- report
   none, so nothing is drawn rather than the wrong glyphs;
6. otherwise the nearest of the fourteen by descriptor flags.

It delegates to the existing per-script selectors instead of restating
their order, so each keeps one source of truth. It answers the
dictionary question only; "which face has a glyph for this character?"
depends on the string being drawn and stays where the drawing happens.

`doc_info` gains `substitute` and `substitution` per font. Both null
means the document's own glyphs are shown; a null `substitute` with a
reason means no face was available at all. That is Acrobat's Actual
Font, minus the interface, which `parity/` now records as still missing.

Two findings on the way:

`Std14::from_base_font` matches loosely, so "SomeHouseSerif" resolved to
Times by its name containing "serif". The face is a fair guess but
reporting it as "names one of the standard 14" would have been untrue of
a font nobody has heard of. `Std14::recognise` now says whether a name
matched an alias or only a family hint, and the two report differently.
`from_base_font` is unchanged.

The generated table moved to `crates/fonts/src/std14_metrics.rs`, data
alone, and the ATTRIBUTION entry and AGENTS.md 1.1 line follow it. F1
put the hash on a file holding both the table and the code reading it,
so this commit's ordinary edits tripped the gate -- and a hash that
churns on unrelated changes gets re-attested reflexively, which is the
protection gone. Verified both directions after the split: editing
std14.rs passes the gate, changing one width in std14_metrics.rs fails
it.

Every new test was watched failing first: removing the script step
breaks the two unit tests and the doc_info integration test.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-13 16:42:10 -07:00
jcl-bit 3f044b6263
fix(print): adopt native Windows spooler
Adopt the canonical craft-print revision from craft-libs #28 so Windows printing uses the in-process Windows.Data.Pdf/XPS backend instead of PowerShell. Keep exact paper, orientation and scaling validation and the existing PDFCraft composition and permission checks.

Pin the reviewed winprint fork, scope its compile-time licence/advisory exceptions, and distinguish the earlier WDAC Sandbox exercise from the final pinned revision's Windows CI coverage. Physical printer output remains unverified.

Add worker-barrier regressions for Home-selected tool handoff and startup document ordering.

Fixes #1107

Co-authored-by: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com>
Co-authored-by: Dimitar Klaturov <7221384+bulgariamitko@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-13 14:46:24 -06:00
skylar taylor-barrick b5467d93a0
fix(ui): preserve native open actions and password prompts
Carry selected tool actions through native file preparation, password retries and conversion, and defer startup intake until pending native opens finish.

Await observable completion in native opening, password, recovery and linked-PDF tests while retaining their content, identity, dirty-state, cancellation and destination checks. Verify the expected validation error and preserve the original document after refused consent targets.

M3; refs #1914.
2026-10-13 12:18:41 -06:00
55 changed files with 2980 additions and 502 deletions

View File

@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
- Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs.
- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
- The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14.rs` (approved by the owner, 2026-10-08), which is what generated appearances measure with and what survives hayro being retired.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_metrics.rs` (approved by the owner, 2026-10-08; that file holds the data alone, so the code reading it can change without re-attesting the table), which is what generated appearances measure with and what survives hayro being retired.
Adding anything to this list needs the user's explicit approval.

View File

@ -222,7 +222,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |

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 = "d0a69a35403e70b94c3adfef88b41ec719f4f6ab700d351c8f82a78c53b63ef6"
sha256 = "389982dcbb7f1ac29fb150c9af43f9529db6e59a091b9971f0401684847d8b53"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "caa23201b7f25dba9f8dc579771e13435e5e8c72a7efb9770e917a2a33003a39"
sha256 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5ead9fccdca17810a3a98de3fa499d8a2469a1ff015c3d0e11880ca629295ea1"
sha256 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d48f9d68711850cae48af53f7691facee4209f7846e3eb2c064d97dad2ae2426"
sha256 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6bc2edec99534d0df9b5e4a1c2e18dc32ad84c32e404c74dd437d86119b1d6ea"
sha256 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cb6c04bd36ba6ce0ebc808da2e47b65797e14d59466e93802adec4d4f5056e3b"
sha256 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0368944155991c2ea304788418fb326f30e45649539a0cf49539194b026ca104"
sha256 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f4969c353f1424c6b9b5b110820c69dc59907590d013221511eae3f86c29024e"
sha256 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4bb6fa4f3b6e236a4a353df79acd0c5528ea8354058eeaeb66c28bc0f7e02939"
sha256 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "adcbdfc683589b067e062a6200084bf9192935b297f5b616f8ed39db9c71db9b"
sha256 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "aebe63cd91df2d2f9d2bdc1da43b017823dbc8a32135e999b9ac7041277be0c8"
sha256 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3c8e3a28a5b751c6d6384809d4fd81c14c2ff5f561aaee5b58d24fbfa2ca4dc1"
sha256 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "637fcfdd0bf1898b413af7ccbc2eb4228bf656273de9f3ef9241bd128940aeab"
sha256 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5285f8987bd8c265f73ff443906c1832a0886037592d334974cf539b9f972a3f"
sha256 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "23779abc08be40ddfb305fd638fe280c854ac33473b2822006ba8bad399c0cad"
sha256 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "c5129c40b202d8467d8d4cf0a189da8c2ba2af711998edb73f67e1b291289a04"
sha256 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "11ed2aa56dc5f8f6656eba717500c74bb8c8f931c08f9f9b210b5b33dd9c2d5a"
sha256 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a3863e2fed5ef584a87f65083a6b9fedf26d18ecebd574efce22bd7a01e3281c"
sha256 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cade94846289c2822b408a2ea23e6c8f8f321708f4f27fd563695a3c4fade6a1"
sha256 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "35b6b03dae01b9a2e98f6d1f682d0699d77772e6efec7e28e1f26039c57b151f"
sha256 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -2365,16 +2365,16 @@ usage = "Default CMYK colour conversion (vendored hayro-interpret)"
sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0"
[[asset]]
path = "crates/fonts/src/std14.rs"
path = "crates/fonts/src/std14_metrics.rs"
title = "Standard-14 font advance widths and encodings (as Rust tables)"
author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors"
source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs."
source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash."
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "data"
adobe_data = true
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
sha256 = "4ce47db7e65467d4ea1d1642ff76dfcaeb9669c1811a93819d7e00e63fa9fcf8"
sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
[[bundled]]
crate = "epaint_default_fonts"
@ -3351,4 +3351,4 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2685fbb694cbdeb7632ecb5e7396e1209047c83319e28ceea032b3ec9fb2fc0e"
sha256 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288"

1130
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ repository = "https://github.com/storytold/pdfcraft"
# 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" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.7.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.7.0" }

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-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@ -148,6 +149,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.

View File

@ -25,7 +25,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
- Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …).
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss.
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
## Text search and cache limits

View File

@ -858,6 +858,10 @@ impl Automation {
}
fn doc_save(&mut self, a: &Args) -> Result<Value> {
self.doc_save_with(a, write_atomic)
}
fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result<Value> {
let doc = self.doc(a)?;
let id = doc.id;
let target = match a.opt_str("path")? {
@ -875,7 +879,7 @@ impl Automation {
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
// redaction), is rewritten in full even when an incremental save was asked for.
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
write_atomic(&target, &bytes)?;
write(&target, &bytes)?;
let path = target.to_string_lossy().into_owned();
self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?;
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) }))
@ -2205,8 +2209,13 @@ fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
// `substitute` names the face standing in for a program the document does not embed, and
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
// `substitute` with a non-null `substitution` means no face was available at all, so
// nothing is drawn for that font -- which is the answer to "why is my text missing?".
"fonts": i.fonts.iter().map(|f| json!({
"name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding,
"substitute": f.substitute, "substitution": f.substitution,
})).collect::<Vec<_>>(),
"warnings": i.warnings,
"repairs": d.repair_log(),
@ -2301,13 +2310,24 @@ fn unused(dir: &Path, stem: &str) -> PathBuf {
(2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first)
}
/// Write via a temporary file in the same directory, then rename over the target.
/// Write and sync a temporary file in the same directory, then rename over the target.
/// This flushes staged contents; it does not promise parent-directory durability after a
/// power loss. Filesystems that refuse sync cause the save to fail before publication.
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
write_atomic_with(path, bytes, staging_suffixes())
}
/// [`write_atomic`], trying the staging names that `suffixes` give.
fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator<Item = u64>) -> Result<()> {
write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all)
}
fn write_atomic_synced(
path: &Path,
bytes: &[u8],
suffixes: impl IntoIterator<Item = u64>,
sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
) -> Result<()> {
use std::io::Write;
// A folder can't be replaced, and staging beside it could land outside the root: "." names
// the root itself, whose parent isn't ours to write in.
@ -2322,7 +2342,9 @@ fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator<Item
// Closed at the end of the block, before the rename.
let written = {
let mut file = file;
file.write_all(bytes)
// Surface delayed writeback failures before replacing the destination or marking
// a document clean. The handle is closed before rename (required on Windows).
file.write_all(bytes).and_then(|()| sync(&file))
};
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
@ -2680,6 +2702,79 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_staging_sync_keeps_the_destination_and_cleans_up() {
for exists in [false, true] {
let dir = staging_dir(if exists { "sync-existing" } else { "sync-new" });
let target = dir.join("out.pdf");
if exists {
std::fs::write(&target, "OLD").unwrap();
}
let result = write_atomic_synced(&target, b"NEW", [1], |_| {
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW", "sync follows writing");
if exists {
assert_eq!(read(&target), "OLD", "sync precedes publication");
}
Err(std::io::Error::other("injected storage sync failure"))
});
assert!(result.is_err(), "sync failure must refuse publication");
assert!(result.unwrap_err().to_string().contains("injected storage sync failure"));
if exists {
assert_eq!(read(&target), "OLD");
assert_eq!(listing(&dir), ["out.pdf"]);
} else {
assert!(!target.exists());
assert!(listing(&dir).is_empty());
}
std::fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() {
let dir = staging_dir("sync-dirty");
let target = dir.join("out.pdf");
let mut a = Automation::new().with_root(&dir).unwrap();
let id = search_document(&mut a, 2);
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
let old = std::fs::read(&target).unwrap();
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
let args = json!({"doc": id.0});
let before = summary(a.doc(&Args(&args)).unwrap());
let result = a.doc_save_with(&Args(&args), |path, bytes| {
write_atomic_synced(path, bytes, [1], |_| Err(std::io::Error::other("injected storage sync failure")))
});
assert!(result.is_err());
let doc = a.doc(&Args(&args)).unwrap();
assert!(doc.dirty);
assert_eq!(summary(doc), before, "failed save must not reset history or saved state");
assert_eq!(std::fs::read(&target).unwrap(), old);
assert_eq!(listing(&dir), ["out.pdf"]);
json(&mut a, "doc_save", args.clone());
assert!(!a.doc(&Args(&args)).unwrap().dirty);
assert_ne!(std::fs::read(&target).unwrap(), old);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn successful_staging_sync_precedes_publication() {
let dir = staging_dir("sync-success");
let target = dir.join("out.pdf");
std::fs::write(&target, "OLD").unwrap();
let mut called = false;
write_atomic_synced(&target, b"NEW", [1], |file| {
called = true;
assert_eq!(read(&target), "OLD");
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW");
file.sync_all()
})
.unwrap();
assert!(called);
assert_eq!(read(&target), "NEW");
assert_eq!(listing(&dir), ["out.pdf"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// The names in a folder: what a test can see was left behind.
fn listing(dir: &Path) -> Vec<String> {
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();

View File

@ -409,6 +409,13 @@ fn open_inspect_render_and_find() {
assert_eq!(info["pages"][0]["width"], 200.0);
assert_eq!(info["pages"][1]["label"], "2");
// The fixture names /Helvetica without embedding it, so an agent can see both that a face is
// standing in and why that face. `substitution` is null only when nothing stands in.
let font = &info["fonts"][0];
assert_eq!((&font["name"], &font["embedded"]), (&json!("Helvetica"), &json!(false)));
assert_eq!(font["substitute"], json!("Helvetica"));
assert!(font["substitution"].as_str().is_some_and(|w| w.contains("standard 14")), "{font}");
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]);
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 2" }));

View File

@ -9,6 +9,7 @@ fn result(auto: &mut Automation, args: Value) -> Value {
}
}
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
fn pdf() -> Vec<u8> {
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
}
@ -49,8 +50,9 @@ fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::write(dir.path().join("input/0.pdf"), b"broken").unwrap();
std::fs::write(dir.path().join("input/1.pdf"), pdf()).unwrap();
std::fs::write(dir.path().join("input/sub/2.pdf"), pdf()).unwrap();
let original = pdf();
std::fs::write(dir.path().join("input/1.pdf"), &original).unwrap();
std::fs::write(dir.path().join("input/sub/2.pdf"), &original).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","stop_on_error":true}));
assert_eq!(report["failed"], 1);
@ -61,7 +63,7 @@ fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() {
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"}));
assert_eq!(report["completed"], 1);
assert!(!dir.path().join("out/sub").exists());
assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= pdf().len() as u64);
assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= original.len() as u64);
}
#[test]
@ -107,12 +109,13 @@ fn explicitly_selected_subfolder_is_processed_without_recursive() {
#[test]
fn explicit_file_can_write_a_numbered_copy_beside_its_source() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
let original = pdf();
std::fs::write(dir.path().join("page.pdf"), &original).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"."}));
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), pdf());
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), pdf());
assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), original);
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), original);
}
#[cfg(unix)]
@ -121,7 +124,32 @@ fn non_utf8_discovered_names_are_reported_without_panicking() {
use std::os::unix::ffi::OsStrExt;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
std::fs::write(dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf")), pdf()).unwrap();
let invalid = dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf"));
if let Err(error) = std::fs::write(&invalid, pdf()) {
// APFS rejects this filename before batch discovery can see it. Keep the
// discovery assertions on filesystems that allow it; no other I/O error
// is an unsupported fixture.
#[cfg(target_os = "macos")]
if error.raw_os_error() == Some(92) {
assert!(!invalid.exists());
assert_eq!(std::fs::read_dir(dir.path().join("input")).unwrap().count(), 0);
assert!(!dir.path().join("out").exists());
let valid = dir.path().join("input/valid-λ.pdf");
let original = pdf();
std::fs::write(&valid, &original).unwrap();
assert_eq!(std::fs::read(&valid).unwrap(), original);
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), original);
eprintln!(
"non-UTF-8 fixture capability unavailable: macOS filesystem rejected creation with EILSEQ92; valid-Unicode batch companion passed; invalid-name discovery still requires Linux/FreeBSD or a supporting filesystem"
);
return;
}
panic!("could not create the non-UTF-8 fixture: {error}");
}
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["failed"], 1);
@ -165,7 +193,8 @@ fn batch_ocr_uses_existing_models_or_reports_the_missing_models_per_file() {
#[test]
fn batch_output_collisions_are_safe_under_concurrent_writers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
let original = pdf();
std::fs::write(dir.path().join("page.pdf"), &original).unwrap();
let barrier = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
let handles: Vec<_> = (0..2)
@ -182,7 +211,7 @@ fn batch_output_collisions_are_safe_under_concurrent_writers() {
assert_ne!(reports[0]["files"][0]["output"], reports[1]["files"][0]["output"]);
});
for name in ["page.pdf", "page (2).pdf"] {
assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), pdf());
assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), original);
}
assert_eq!(std::fs::read_dir(dir.path().join("out")).unwrap().count(), 2, "no staging files remain");
}

View File

@ -216,6 +216,11 @@ pub const COMMANDS: &[CommandSpec] = &[
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
// Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS,
// where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and
// also run from Combine files, which has no document open.
c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"),
c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"),
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"),
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),

View File

@ -0,0 +1,370 @@
//! The one chain PdfCraft follows when a document does not embed a font program, and a record of
//! what it chose and why.
//!
//! Substitution used to be decided per caller and never written down: `edit` picked a craft face
//! by the script of the text it was drawing, `Std14::from_base_font` matched Latin names, and the
//! renderer did its own thing. Nothing told the *user* that the glyphs they are looking at are not
//! the ones the document asked for, which is the difference between "PdfCraft shows nothing for my
//! Chinese PDF" being a mystery and being a sentence.
//!
//! This module answers the *dictionary* question — "the document does not carry this program; what
//! will stand in for it, and why?" — which is what inspection and reporting need. It deliberately
//! does not answer the *text* question ("which face has a glyph for こ?"), because that depends on
//! the string being drawn and is already handled where the drawing happens. It delegates to the
//! existing per-script selectors rather than restating their order, so there is one source of
//! truth for each.
//!
//! The chain is tried in order, and the first step that yields a face wins:
//!
//! 1. The document embeds the program. Nothing is substituted.
//! 2. `/BaseFont` names one of the standard 14 (or a well-known alias such as Arial). Exact
//! published metrics apply, so this is as close to right as a substitution gets.
//! 3. craft-fonts holds that exact family. Never another family — see [`crate::embedding`].
//! 4. The descendant font's `/CIDSystemInfo /Ordering` names a script, and craft-fonts has a face
//! for it. Today that means Adobe-Japan1.
//! 5. The ordering names a script we have **no** licensed face for (GB1, CNS1, Korea1). There is
//! no honest substitute, so the chain reports none: the caller draws nothing and can say why.
//! 6. Otherwise, the nearest of the standard 14 by `/FontDescriptor /Flags` — serif, fixed pitch,
//! bold and italic. This step always succeeds, so a simple font is never left without a face.
use crate::{CraftFont, Std14};
/// What a font dictionary asks for, as far as the dictionary alone can say.
#[derive(Clone, Copy, Debug, Default)]
pub struct Request<'a> {
/// `/BaseFont` with any `ABCDEF+` subset tag already stripped.
pub base_font: &'a str,
/// Whether the descriptor carries `/FontFile`, `/FontFile2` or `/FontFile3` (or it is Type 3).
pub embedded: bool,
/// `/FontDescriptor /Flags`, or 0 when there is no descriptor.
pub flags: u32,
/// The descendant font's `/CIDSystemInfo /Ordering`, for a composite font.
pub ordering: Option<&'a str>,
}
/// The face the chain picked.
///
/// `Debug`, `PartialEq` and `Eq` are written by hand rather than derived, because [`CraftFont`]
/// carries a whole font program: deriving would print megabytes and compare font files.
#[derive(Clone, Copy)]
pub enum Face {
/// The document's own program; nothing was substituted.
Embedded,
/// One of the standard 14, whose metrics are exact.
Standard14(Std14),
/// A face from the optional craft-fonts build input.
Craft(&'static CraftFont),
/// No face at all. The caller must draw nothing rather than draw the wrong glyphs.
None,
}
impl PartialEq for Face {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Embedded, Self::Embedded) | (Self::None, Self::None) => true,
(Self::Standard14(a), Self::Standard14(b)) => a == b,
// craft-fonts entries are unique statics, so identity is the comparison that means
// what we want: two styles of one family are different faces and must not be equal.
(Self::Craft(a), Self::Craft(b)) => std::ptr::eq(*a, *b),
_ => false,
}
}
}
impl Eq for Face {}
impl std::fmt::Debug for Face {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Embedded => f.write_str("Embedded"),
Self::Standard14(s) => write!(f, "Standard14({})", s.base_font()),
// The name, never the bytes.
Self::Craft(c) => write!(f, "Craft({})", c.name()),
Self::None => f.write_str("None"),
}
}
}
/// Why the chain picked that face. The variants are in the order the chain tries them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Why {
/// The document carries the font program.
Embedded,
/// `/BaseFont` names a standard-14 face or a well-known alias of one.
NamedStandard14,
/// `/BaseFont` is otherwise unknown, but its name hints at a family ("Serif", "Roman",
/// "Mono"). A guess from the name, and reported as one.
NameHintedStandard14,
/// craft-fonts holds this exact family.
ExactCraftFamily,
/// A craft-fonts face for the script named by `/CIDSystemInfo /Ordering`.
ScriptFace,
/// The ordering names a script with no licensed face available.
NoFaceForScript,
/// The nearest standard-14 face by descriptor flags.
StyleMatched,
}
impl Why {
/// A stable sentence for a report, written for whoever has to act on it.
pub fn note(self) -> &'static str {
match self {
Self::Embedded => "embedded in the document; not substituted",
Self::NamedStandard14 => "names one of the standard 14 fonts, or a well-known alias of one, whose metrics are exact",
Self::NameHintedStandard14 => {
"not embedded and not a known family, but its name hints at one (Serif, Roman or Mono), so the nearest standard-14 face was chosen from that hint"
}
Self::ExactCraftFamily => "this exact family is available, so no other family was chosen",
Self::ScriptFace => "substituted by a face for the script the font declares",
Self::NoFaceForScript => {
"no licensed face is available for the script this font declares, so nothing is drawn for it \
rather than the wrong glyphs"
}
Self::StyleMatched => "not embedded and not a known family, so the nearest standard-14 face was chosen by its descriptor flags",
}
}
/// Whether the document's own glyphs are being shown. Only [`Why::Embedded`] is faithful.
pub fn is_faithful(self) -> bool {
matches!(self, Self::Embedded)
}
}
/// What the chain chose, and why.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Substitution {
pub face: Face,
pub why: Why,
}
impl Substitution {
/// The chosen face's name, for a report. `None` when nothing was chosen.
pub fn face_name(&self) -> Option<String> {
match self.face {
Face::Embedded => None,
Face::Standard14(f) => Some(f.base_font().to_owned()),
Face::Craft(f) => Some(f.name()),
Face::None => None,
}
}
}
/// CID orderings we recognise, and whether a craft-fonts face may exist for them.
///
/// `Identity` is not a script: it says the codes are glyph ids, so it tells us nothing about
/// which face would suit and the chain falls through to the flags.
fn script_for(ordering: &str) -> Option<&'static str> {
match ordering {
"Japan1" => Some("Jpan"),
// Registered orderings we have no openly licensed face for. AGENTS.md 1.1 rules out
// Source Han and Noto CJK (Source Han rebranded), which is why these have none.
"GB1" | "CNS1" | "Korea1" | "KR" => Some(""),
_ => None,
}
}
/// Apply the chain to `req`. Total: every request gets a [`Substitution`].
///
/// See the module documentation for the order and the reasoning behind each step.
pub fn substitute(req: &Request<'_>) -> Substitution {
let at = |face, why| Substitution { face, why };
if req.embedded {
return at(Face::Embedded, Why::Embedded);
}
if let Some((f, how)) = Std14::recognise(req.base_font) {
return match how {
crate::std14::Match::Alias => at(Face::Standard14(f), Why::NamedStandard14),
crate::std14::Match::FamilyHint => at(Face::Standard14(f), Why::NameHintedStandard14),
};
}
if let Some(f) = crate::embedding::matching_faces(req.base_font).first() {
return at(Face::Craft(f), Why::ExactCraftFamily);
}
if let Some(script) = req.ordering.and_then(script_for) {
// A script we hold a face for; otherwise there is no honest substitute to offer.
return match crate::CRAFT_FONTS.iter().find(|f| f.covers(script)) {
Some(f) => at(Face::Craft(f), Why::ScriptFace),
None => at(Face::None, Why::NoFaceForScript),
};
}
at(Face::Standard14(style_matched(req.flags)), Why::StyleMatched)
}
/// The nearest standard-14 face for `/FontDescriptor /Flags` (ISO 32000-2 table 121): fixed pitch
/// is bit 1, serif bit 2, italic bit 7 and ForceBold bit 19.
fn style_matched(flags: u32) -> Std14 {
let (bold, italic) = (flags & 0x4_0000 != 0, flags & 0x40 != 0);
match (flags & 1 != 0, flags & 2 != 0, bold, italic) {
(true, _, false, false) => Std14::Courier,
(true, _, true, false) => Std14::CourierBold,
(true, _, false, true) => Std14::CourierOblique,
(true, _, true, true) => Std14::CourierBoldOblique,
(false, true, false, false) => Std14::TimesRoman,
(false, true, true, false) => Std14::TimesBold,
(false, true, false, true) => Std14::TimesItalic,
(false, true, true, true) => Std14::TimesBoldItalic,
(false, false, false, false) => Std14::Helvetica,
(false, false, true, false) => Std14::HelveticaBold,
(false, false, false, true) => Std14::HelveticaOblique,
(false, false, true, true) => Std14::HelveticaBoldOblique,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn req<'a>(base: &'a str, embedded: bool, flags: u32, ordering: Option<&'a str>) -> Request<'a> {
Request { base_font: base, embedded, flags, ordering }
}
#[test]
fn an_embedded_font_is_never_substituted() {
// True even when the name and flags would otherwise pick something: embedding wins first.
for (base, ordering) in [("Helvetica", None), ("Whatever", Some("GB1")), ("", None)] {
let s = substitute(&req(base, true, 0, ordering));
assert_eq!(s, Substitution { face: Face::Embedded, why: Why::Embedded });
assert!(s.why.is_faithful());
assert_eq!(s.face_name(), None, "nothing stands in, so there is no substitute to name");
}
}
#[test]
fn a_standard_14_name_wins_over_the_flags() {
// Flags claiming a fixed-pitch serif must not override a name we have exact metrics for.
let s = substitute(&req("Times-BoldItalic", false, 0b11, None));
assert_eq!(s.face, Face::Standard14(Std14::TimesBoldItalic));
assert_eq!(s.why, Why::NamedStandard14);
assert_eq!(s.face_name().as_deref(), Some("Times-BoldItalic"));
// Aliases and subset tags resolve the same way.
for name in ["Arial", "ArialMT", "Arial-Bold", "Symbol", "ZapfDingbats", "CourierNew"] {
assert_eq!(substitute(&req(name, false, 0, None)).why, Why::NamedStandard14, "{name}");
}
}
#[test]
fn a_name_hint_is_reported_as_a_guess_not_as_a_standard_14_name() {
// A house font whose name merely contains "Serif" gets Times, which is a sensible guess --
// but calling that "names one of the standard 14" would tell the reader something untrue.
let hinted = substitute(&req("SomeHouseSerif", false, 0, None));
assert_eq!(hinted.face, Face::Standard14(Std14::TimesRoman));
assert_eq!(hinted.why, Why::NameHintedStandard14);
assert!(hinted.why.note().contains("hints at one"), "{}", hinted.why.note());
// Style words still apply to the guess.
assert_eq!(substitute(&req("SomeHouseSerif-BoldItalic", false, 0, None)).face, Face::Standard14(Std14::TimesBoldItalic));
// "Mono" hints at Courier the same way.
assert_eq!(substitute(&req("HouseMono", false, 0, None)).why, Why::NameHintedStandard14);
// A real alias is still reported as the request it is.
for alias in ["Arial", "CourierNew", "Times-Roman", "Symbol"] {
assert_eq!(substitute(&req(alias, false, 0, None)).why, Why::NamedStandard14, "{alias}");
}
// A name with no hint at all falls through to the flags instead.
assert_eq!(substitute(&req("HouseGrotesk", false, 0b10, None)).why, Why::StyleMatched);
}
#[test]
fn flags_pick_the_nearest_of_the_fourteen() {
// Bit 1 fixed pitch, bit 2 serif, bit 7 italic, bit 19 ForceBold.
let cases = [
(0, Std14::Helvetica),
(0b10, Std14::TimesRoman),
(0b1, Std14::Courier),
(0x40, Std14::HelveticaOblique),
(0x4_0000, Std14::HelveticaBold),
(0x4_0040, Std14::HelveticaBoldOblique),
(0b10 | 0x40, Std14::TimesItalic),
(0b10 | 0x4_0000, Std14::TimesBold),
(0b10 | 0x4_0040, Std14::TimesBoldItalic),
(0b1 | 0x40, Std14::CourierOblique),
(0b1 | 0x4_0000, Std14::CourierBold),
(0b1 | 0x4_0040, Std14::CourierBoldOblique),
// Fixed pitch and serif together: monospace is the stronger signal for layout.
(0b11, Std14::Courier),
];
for (flags, want) in cases {
let s = substitute(&req("SomethingUnknown", false, flags, None));
assert_eq!(s.face, Face::Standard14(want), "flags {flags:#x}");
assert_eq!(s.why, Why::StyleMatched);
}
}
#[test]
fn a_script_with_no_licensed_face_reports_none_rather_than_latin() {
// The user's Chinese PDF draws nothing. The point of this is that we can now say so,
// instead of quietly standing Helvetica in and showing the wrong glyphs.
for ordering in ["GB1", "CNS1", "Korea1", "KR"] {
let s = substitute(&req("SomeCJKFont", false, 0, Some(ordering)));
assert_eq!(s.face, Face::None, "{ordering}");
assert_eq!(s.why, Why::NoFaceForScript, "{ordering}");
assert_eq!(s.face_name(), None);
assert!(!s.why.is_faithful());
assert!(s.why.note().contains("no licensed face"), "{ordering}");
}
}
#[test]
fn identity_ordering_is_not_a_script_and_falls_through_to_the_flags() {
// /Ordering (Identity) says the codes are glyph ids. It implies nothing about the face,
// so treating it as a script would invent a substitution the dictionary never asked for.
let s = substitute(&req("SomeSubsetFont", false, 0b10, Some("Identity")));
assert_eq!(s.face, Face::Standard14(Std14::TimesRoman));
assert_eq!(s.why, Why::StyleMatched);
// An unregistered ordering is treated the same way, not guessed at.
assert_eq!(substitute(&req("X", false, 0, Some("MadeUp1"))).why, Why::StyleMatched);
}
#[test]
fn japanese_depends_on_craft_fonts_and_says_which_it_got() {
// craft-fonts is an optional build input, so both outcomes are correct -- but they must be
// distinguishable, and neither may be a silent Latin stand-in.
let s = substitute(&req("SomeJapaneseFont", false, 0, Some("Japan1")));
if crate::CRAFT_FONTS.iter().any(|f| f.covers("Jpan")) {
assert_eq!(s.why, Why::ScriptFace);
assert!(matches!(s.face, Face::Craft(_)));
assert!(s.face_name().is_some_and(|n| !n.is_empty()));
} else {
assert_eq!(s.why, Why::NoFaceForScript);
assert_eq!(s.face, Face::None);
}
assert!(!s.why.is_faithful(), "a substitution is never faithful");
}
#[test]
fn every_why_has_a_distinct_usable_note() {
let all = [
Why::Embedded,
Why::NamedStandard14,
Why::NameHintedStandard14,
Why::ExactCraftFamily,
Why::ScriptFace,
Why::NoFaceForScript,
Why::StyleMatched,
];
let mut notes: Vec<&str> = all.iter().map(|w| w.note()).collect();
notes.sort_unstable();
let before = notes.len();
notes.dedup();
assert_eq!(notes.len(), before, "each reason reads differently");
assert!(all.iter().all(|w| !w.note().is_empty() && w.note().len() > 20), "notes are sentences, not labels");
assert_eq!(all.iter().filter(|w| w.is_faithful()).count(), 1, "only an embedded program is faithful");
}
#[test]
fn hostile_input_is_total_and_never_panics() {
// Every field is attacker-controlled: a name is any bytes the document chose.
let long = "a".repeat(10_000);
let names = ["", " ", "+", "ABCDEF+", "\u{0}\u{1}", long.as_str(), "../../etc/passwd", "Ĥéļvéťïçà"];
for name in names {
for flags in [0, u32::MAX, 0x4_0040, 1] {
for ordering in [None, Some(""), Some("Japan1"), Some("\u{0}")] {
let s = substitute(&req(name, false, flags, ordering));
// Always a decision, and the reason always reads.
assert!(!s.why.note().is_empty());
assert_eq!(s.face == Face::None, s.why == Why::NoFaceForScript, "only one step declines: {name:?}");
}
}
}
}
}

View File

@ -10,11 +10,13 @@
mod craft;
pub mod embedding;
mod encodings;
pub mod fallback;
pub mod pdf;
mod script;
mod shaping;
pub mod source_shaping;
mod std14;
mod std14_metrics;
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts,

View File

@ -32,44 +32,60 @@ pub enum Std14 {
ZapfDingbats,
}
/// How [`Std14::recognise`] matched a `/BaseFont` name.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Match {
/// A standard-14 face or a well-known alias of one (Arial for Helvetica, CourierNew for
/// Courier). The document is asking for this face.
Alias,
/// Only a family hint matched -- "Serif", "Roman" or "Mono" in an otherwise unknown name.
/// The face is a guess from the name, not the one the document asked for.
FamilyHint,
}
impl Std14 {
/// The font a `/BaseFont` name selects, by the spec's names and the common aliases viewers
/// accept (`Arial` for `Helvetica`, `TimesNewRoman` for `Times`, `CourierNew` for `Courier`).
/// A subset prefix such as `ABCDEF+Helvetica` is ignored.
pub fn from_base_font(base: &str) -> Option<Self> {
Self::recognise(base).map(|(face, _)| face)
}
/// [`Std14::from_base_font`], also saying *how* the name matched.
///
/// Reporting needs the difference. "Arial" really is a request for Helvetica, but
/// "SomeHouseSerif" is a house font whose name merely contains "Serif"; choosing Times for it
/// is a reasonable guess from the name, not the face the document asked for, and a substitution
/// report that called it a standard-14 name would be telling the reader something untrue.
pub fn recognise(base: &str) -> Option<(Self, Match)> {
let base = base.rsplit('+').next().unwrap_or(base);
let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect();
let has = |w: &str| flat.contains(w);
let (bold, italic) = (has("bold"), has("italic") || has("oblique"));
// Two bools index a four-element array, so the subscript is provably 0..=3.
let styled = |faces: [Self; 4]| faces[usize::from(bold) | usize::from(italic) << 1];
let courier = || styled([Self::Courier, Self::CourierBold, Self::CourierOblique, Self::CourierBoldOblique]);
let times = || styled([Self::TimesRoman, Self::TimesBold, Self::TimesItalic, Self::TimesBoldItalic]);
if has("symbol") {
return Some(Self::Symbol);
return Some((Self::Symbol, Match::Alias));
}
if has("zapf") || has("dingbat") {
return Some(Self::ZapfDingbats);
return Some((Self::ZapfDingbats, Match::Alias));
}
if has("courier") || has("mono") {
return Some(match (bold, italic) {
(false, false) => Self::Courier,
(true, false) => Self::CourierBold,
(false, true) => Self::CourierOblique,
(true, true) => Self::CourierBoldOblique,
});
if has("courier") {
return Some((courier(), Match::Alias));
}
if has("times") || has("serif") || has("roman") {
return Some(match (bold, italic) {
(false, false) => Self::TimesRoman,
(true, false) => Self::TimesBold,
(false, true) => Self::TimesItalic,
(true, true) => Self::TimesBoldItalic,
});
if has("mono") {
return Some((courier(), Match::FamilyHint));
}
if has("times") {
return Some((times(), Match::Alias));
}
if has("serif") || has("roman") {
return Some((times(), Match::FamilyHint));
}
if has("helvetica") || has("arial") {
return Some(match (bold, italic) {
(false, false) => Self::Helvetica,
(true, false) => Self::HelveticaBold,
(false, true) => Self::HelveticaOblique,
(true, true) => Self::HelveticaBoldOblique,
});
return Some((styled([Self::Helvetica, Self::HelveticaBold, Self::HelveticaOblique, Self::HelveticaBoldOblique]), Match::Alias));
}
None
}
@ -77,7 +93,7 @@ impl Std14 {
/// The advance of one character `code`, in thousandths of an em. Zero where the font's
/// encoding leaves the code undefined.
pub fn width(self, code: u8) -> f64 {
f64::from(WIDTHS[self as usize][code as usize])
f64::from(crate::std14_metrics::WIDTHS[self as usize][code as usize])
}
/// The advance of `code` at `size` points.
@ -101,298 +117,10 @@ impl Std14 {
/// The `/BaseFont` name for this font, as it is written into a PDF.
pub fn base_font(self) -> &'static str {
BASE_FONTS[self as usize]
crate::std14_metrics::BASE_FONTS[self as usize]
}
}
static BASE_FONTS: [&str; 14] = [
"Helvetica",
"Helvetica-Bold",
"Helvetica-Oblique",
"Helvetica-BoldOblique",
"Times-Roman",
"Times-Bold",
"Times-Italic",
"Times-BoldItalic",
"Courier",
"Courier-Bold",
"Courier-Oblique",
"Courier-BoldOblique",
"Symbol",
"ZapfDingbats",
];
/// Advance widths by code, one row per [`Std14`] variant in declaration order.
#[rustfmt::skip]
static WIDTHS: [[u16; 256]; 14] = [
// Helvetica
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
],
// HelveticaBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
],
// HelveticaOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
],
// HelveticaBoldOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
],
// TimesRoman
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444,
921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722,
556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500,
333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500,
500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0,
500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0,
0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722,
250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333,
400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444,
722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500,
444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500,
],
// TimesBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778,
611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500,
333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500,
556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0,
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0,
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722,
250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333,
400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389,
722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556,
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500,
],
// TimesItalic
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500,
920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722,
611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500,
333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500,
500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0,
500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0,
0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556,
250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333,
400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500,
611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500,
500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444,
],
// TimesBoldItalic
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722,
611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500,
333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500,
500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0,
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0,
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611,
250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333,
400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500,
667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389,
722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500,
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444,
],
// Courier
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierBoldOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// Symbol
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444,
549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722,
768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500,
500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549,
549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603,
400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658,
823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713,
768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603,
494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494,
0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0,
],
// ZapfDingbats
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933,
911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537,
577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831,
923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826,
815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762,
762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0,
390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788,
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834,
873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874,
0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0,
],
];
#[cfg(test)]
mod tests {
use super::*;

View File

@ -0,0 +1,300 @@
//! The generated standard-14 tables: the fourteen base font names and their per-code advance
//! widths. **Data only, no logic** -- see `std14.rs` for everything that interprets it.
//!
//! This file is the closed-list item in AGENTS.md 1.1 and carries a SHA-256 in `ATTRIBUTION.toml`,
//! so `cargo xtask assets` fails until a change to it is re-attested. It is kept separate from the
//! code that reads it for exactly that reason: if ordinary edits churned the hash, re-attesting
//! would become reflexive and the check would stop meaning anything.
//!
//! Widths are in thousandths of an em, indexed by character code in each face's own encoding --
//! WinAnsi for the twelve text faces, and their built-in encodings for Symbol and ZapfDingbats.
//! A zero means the encoding leaves that code undefined. Verified against the published
//! specification by the tests in `std14.rs`, which type the expected values in by hand.
pub(crate) static BASE_FONTS: [&str; 14] = [
"Helvetica",
"Helvetica-Bold",
"Helvetica-Oblique",
"Helvetica-BoldOblique",
"Times-Roman",
"Times-Bold",
"Times-Italic",
"Times-BoldItalic",
"Courier",
"Courier-Bold",
"Courier-Oblique",
"Courier-BoldOblique",
"Symbol",
"ZapfDingbats",
];
/// Advance widths by code, one row per [`Std14`] variant in declaration order.
#[rustfmt::skip]
pub(crate) static WIDTHS: [[u16; 256]; 14] = [
// Helvetica
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
],
// HelveticaBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
],
// HelveticaOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
],
// HelveticaBoldOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
],
// TimesRoman
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444,
921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722,
556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500,
333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500,
500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0,
500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0,
0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722,
250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333,
400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444,
722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500,
444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500,
],
// TimesBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778,
611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500,
333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500,
556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0,
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0,
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722,
250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333,
400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389,
722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556,
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500,
],
// TimesItalic
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500,
920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722,
611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500,
333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500,
500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0,
500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0,
0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556,
250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333,
400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500,
611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500,
500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444,
],
// TimesBoldItalic
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722,
611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500,
333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500,
500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0,
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0,
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611,
250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333,
400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500,
667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389,
722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500,
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444,
],
// Courier
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierBoldOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// Symbol
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444,
549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722,
768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500,
500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549,
549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603,
400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658,
823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713,
768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603,
494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494,
0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0,
],
// ZapfDingbats
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933,
911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537,
577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831,
923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826,
815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762,
762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0,
390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788,
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834,
873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874,
0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0,
],
];

View File

@ -71,7 +71,7 @@ this is not per-page paper-source selection.
**PDF scale** describes the source-to-sheet transform in the previewed print-ready
PDF, not measured physical output. Fit reserves an 18 pt margin and can reduce even
a matching page. The current Windows backend still rasterises and can substitute
paper/shrink a job if its driver lacks the requested size. These controls do not
add #599's strict PrintTicket validation or vector spooler. Verify the driver's
paper/scaling settings and physical calibration before relying on drawing scale.
a matching page. On Windows craft-print sends the sheets as vectors with a PrintTicket
for the sheet's exact paper and no scaling (#599's approach), validated by the driver:
a job is refused, not shrunk, when the driver lacks the paper or would change it. Verify
the driver's paper settings and physical calibration before relying on drawing scale.

View File

@ -365,6 +365,13 @@ pub struct FontInfo {
pub embedded: bool,
pub subset: bool,
pub encoding: Option<String>,
/// The face standing in for a program the document does not embed. `None` when the font is
/// embedded (nothing stands in) or when no face was available at all -- `substitution` then
/// says which of the two it is.
pub substitute: Option<String>,
/// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when
/// the document's own glyphs are being shown.
pub substitution: Option<String>,
}
/// Check that `bytes` still open as the same document: the parser reads them and the page
@ -1290,20 +1297,38 @@ impl<'a> Inspector<'a> {
_ => None,
};
// Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font).
let descriptor_of = |d: &Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)).cloned();
let desc = descriptor_of(f).or_else(|| {
f.get(b"DescendantFonts")
.ok()
.and_then(|o| self.resolve(o).as_array().ok())
.and_then(|a| a.first())
.and_then(|o| self.dict(o))
.and_then(descriptor_of)
});
let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o));
let descendant =
f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o));
let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of));
let embedded =
kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok());
// What the substitution chain needs beyond the name: the descriptor flags, and for
// a composite font the script its descendant declares (ISO 32000-2 9.7.3).
let flags = desc
.and_then(|d| d.get(b"Flags").ok())
.and_then(|o| self.resolve(o).as_i64().ok())
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0);
let ordering =
descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering"));
let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
let name = if subset { base[7..].to_string() } else { base };
let info = FontInfo { name, kind, embedded, subset, encoding };
let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request {
base_font: &name,
embedded,
flags,
ordering: ordering.as_deref(),
});
let info = FontInfo {
name,
kind,
embedded,
subset,
encoding,
substitute: chosen.face_name(),
substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()),
};
if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) {
out.push(info);
}
@ -1981,7 +2006,20 @@ trailer << /Root 1 0 R >>
let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" };
assert_eq!(data, expected, "{}", a.name);
}
assert_eq!(info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None }]);
// A non-embedded /Helvetica is reported as standing in for itself: the standard-14
// metrics are exact, so the substitution is named rather than left implicit.
assert_eq!(
info.fonts,
vec![FontInfo {
name: "Helvetica".into(),
kind: "Type1".into(),
embedded: false,
subset: true,
encoding: None,
substitute: Some("Helvetica".into()),
substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()),
}]
);
}
const LAYERS: &[u8] = b"%PDF-1.7
@ -2520,3 +2558,97 @@ mod version_tests {
assert_eq!(super::version_label("Unknown"), "Unknown");
}
}
#[cfg(test)]
mod substitution_tests {
use std::sync::Arc;
use super::{FontInfo, inspect};
/// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a
/// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased
/// standard-14 name.
const NOT_EMBEDDED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj
4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj
5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj
6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj
7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj
8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj
9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj
10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj
11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj
12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj
trailer << /Size 13 /Root 1 0 R >>
%%EOF";
fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo {
fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::<Vec<_>>()))
}
#[test]
fn doc_info_says_which_face_stands_in_and_why() {
let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens");
// The one that matters most to a user: a Chinese PDF that draws nothing. Before this,
// doc_info said only `embedded: false` and left them to guess.
let cjk = font(&info.fonts, "STSong-Light");
assert!(!cjk.embedded);
assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in");
let why = cjk.substitution.as_deref().unwrap_or_default();
assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}");
// An aliased standard-14 name resolves to the real face, whose metrics are exact.
let arial = font(&info.fonts, "ArialMT");
assert_eq!(arial.substitute.as_deref(), Some("Helvetica"));
assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14"));
// A house font whose name merely contains "Serif" gets Times -- reported as the guess it
// is, not as a standard-14 name, which would be untrue of a font nobody has heard of.
let serif = font(&info.fonts, "SomeHouseSerif");
assert_eq!(serif.substitute.as_deref(), Some("Times-Roman"));
assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}");
// No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic.
let grotesk = font(&info.fonts, "HouseGrotesk");
assert_eq!(grotesk.substitute.as_deref(), Some("Courier"));
assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}");
// Japanese depends on the optional craft-fonts build input; either way it is reported,
// and never as a Latin face.
let jpan = font(&info.fonts, "KozMinPro-Regular");
assert!(jpan.substitution.is_some(), "a substitution is always explained");
match &jpan.substitute {
Some(face) => {
assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}")
}
None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")),
}
// Nothing claims to be embedded, so every font here carries a reason.
assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts);
}
/// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain.
const EMBEDDED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj
4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj
5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj
6 0 obj << /Length 4 >> stream
test
endstream endobj
trailer << /Size 7 /Root 1 0 R >>
%%EOF";
#[test]
fn an_embedded_font_reports_no_substitution_at_all() {
let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens");
let f = font(&info.fonts, "SomeHouseSans");
assert!(f.embedded && f.subset, "{f:?}");
assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown");
}
}

View File

@ -98,15 +98,19 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) {
scroll_to = Some((offset - (TAB_MIN_WIDTH + gap)).max(0.0));
}
let state = (app.active, app.views.len(), app.combine_showing());
// Keyed by document, not index: a tab dragged past the active one moves it without
// the strip jumping to it.
let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing());
let changed = ui.data_mut(|data| {
let id = ui.id().with("active_tab");
let changed = data.get_temp::<(Option<usize>, usize, bool)>(id) != Some(state);
let changed = data.get_temp::<(Option<pdfcraft_engine::DocId>, usize, bool)>(id) != Some(state);
data.insert_temp(id, state);
changed
});
let mut action = None;
let mut close = None;
// The pointer's x while a tab is dragged, for scrolling at the strip's edges.
let mut drag_x = None;
ui.scope(|ui| {
ui.style_mut().always_scroll_the_only_direction = true;
let mut area = egui::ScrollArea::horizontal().id_salt("document_tabs").max_width(tabs_width).auto_shrink([true, true]);
@ -115,6 +119,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
let out = area.show(ui, |ui| {
ui.horizontal_centered(|ui| {
// Each document tab's index and rect, and the one being dragged
// (with whether it was just released).
let mut slots = Vec::with_capacity(app.views.len());
let mut dragged = None;
for i in 0..app.views.len() {
let Some(doc) = app.session.get(app.views[i].id) else { continue };
let (name, dirty) = (doc.display_name(), doc.dirty);
@ -133,7 +141,12 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
i,
cap,
tab_h,
true,
);
slots.push((i, response.rect));
if response.dragged() || response.drag_stopped() {
dragged = Some((i, response.drag_stopped()));
}
if changed && app.active == Some(i) && !app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
@ -158,6 +171,25 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.active = Some(i);
}
}
// Dragging a tab moves it (#1537): a line marks the gap it will land
// in, and releasing the button drops it there.
if let Some((from, released)) = dragged
&& let Some(x) = ui.ctx().pointer_latest_pos().map(|p| p.x)
{
let to = drop_index(&slots, from, x);
if released {
if close.is_none() && action.is_none() {
app.move_tab(from, to);
}
} else {
drag_x = Some(x);
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) {
let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range());
ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent));
}
}
}
if let Some(i) = close
&& let Some(view) = app.views.get(i)
{
@ -182,6 +214,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
usize::MAX,
cap,
tab_h,
false,
);
if changed && app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
@ -197,6 +230,22 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
let max = (out.content_size.x - out.inner_rect.width()).max(0.0);
ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max)));
// A tab dragged to either edge of a strip that scrolls brings the hidden tabs in.
if let Some(x) = drag_x
&& max > 0.0
{
let step = if x < out.inner_rect.left() + TAB_DRAG_EDGE {
-TAB_DRAG_STEP
} else if x > out.inner_rect.right() - TAB_DRAG_EDGE {
TAB_DRAG_STEP
} else {
0.0
};
if step != 0.0 {
ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max)));
ui.ctx().request_repaint();
}
}
});
if overflow && arrow(ui, "chevron-right", offset < max_offset - 0.5, tl!("Scroll tabs right")) {
scroll_to = Some((offset + TAB_MIN_WIDTH + gap).min(max_offset));
@ -289,6 +338,29 @@ fn tabs_overflow(natural: &[f32], budget: f32, gap: f32, min: f32) -> bool {
narrowest + gap * natural.len().saturating_sub(1) as f32 > budget
}
/// How close to either end of a scrolling strip a dragged tab scrolls it, and how far per frame.
const TAB_DRAG_EDGE: f32 = 24.0;
const TAB_DRAG_STEP: f32 = 8.0;
/// Where the tab at `from` lands when dropped with the pointer at `x`: past every other tab
/// whose centre is left of the pointer. `slots` are the tabs' indices and rects, in order.
fn drop_index(slots: &[(usize, Rect)], from: usize, x: f32) -> usize {
slots.iter().filter(|(i, r)| *i != from && r.center().x < x).count()
}
/// The x of the line marking where the tab at `from` drops at `to`: in the gap before the other
/// tab it lands in front of, or after the last. `None` when it would stay where it is.
fn drop_marker(slots: &[(usize, Rect)], from: usize, to: usize, gap: f32) -> Option<f32> {
if slots.iter().position(|(i, _)| *i == from) == Some(to) {
return None;
}
let mut others = slots.iter().filter(|(i, _)| *i != from).map(|(_, r)| r);
match to.checked_sub(1) {
None => others.next().map(|r| r.left() - gap / 2.0),
Some(before) => others.nth(before).map(|r| r.right() + gap / 2.0),
}
}
/// A scroll arrow beside the tabs; dimmed (and inert) at its end. True when clicked.
fn arrow(ui: &mut egui::Ui, icon: &str, enabled: bool, tip: &str) -> bool {
ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, TAB_ARROW, false, tip)).inner.clicked()
@ -343,6 +415,7 @@ fn tab(
index: usize,
cap: Option<f32>,
height: f32,
draggable: bool,
) -> egui::Response {
let font = theme::regular(13.0);
let mut label = tab_name(name);
@ -361,7 +434,8 @@ fn tab(
let height = ui.available_height().max(height);
let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() };
let resp = craft_ui::tabs::Tab::new(id, &a11y, active).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
let sense = if draggable { Sense::click_and_drag() } else { Sense::click() };
let resp = craft_ui::tabs::Tab::new(id, &a11y, active).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
let press = theme::Press::track(ui, resp);
let bg = if active {
press.fill(t.chrome, t.chrome.lerp_to_gamma(t.hover, 0.65), t.hover)
@ -750,6 +824,41 @@ mod tab_widths {
assert_eq!(tab_cap(&[250.0; 3], 0.0, 4.0, 110.0), Some(110.0));
}
/// Three 100-wide tabs, 4 apart, starting at x = 0.
fn three_slots() -> Vec<(usize, egui::Rect)> {
(0..3).map(|i| (i, egui::Rect::from_min_size(egui::pos2(i as f32 * 104.0, 0.0), egui::vec2(100.0, 30.0)))).collect()
}
/// A dragged tab lands past every other tab whose centre the pointer has crossed.
#[test]
fn a_dragged_tab_drops_past_the_centres_it_crossed() {
use super::drop_index;
let slots = three_slots();
// Centres at 50, 154 and 258.
assert_eq!(drop_index(&slots, 0, 10.0), 0, "still over itself");
assert_eq!(drop_index(&slots, 0, 160.0), 1, "past the middle tab's centre");
assert_eq!(drop_index(&slots, 0, 900.0), 2, "past the end");
assert_eq!(drop_index(&slots, 2, -50.0), 0, "before the start");
assert_eq!(drop_index(&slots, 2, 100.0), 1, "between the first and middle centres");
assert_eq!(drop_index(&[], 0, 100.0), 0);
}
/// The drop line sits in the gap the tab will land in, and not at all where it already is.
#[test]
fn the_drop_line_marks_the_gap_and_hides_when_nothing_moves() {
use super::drop_marker;
let slots = three_slots();
assert_eq!(drop_marker(&slots, 0, 0, 4.0), None);
assert_eq!(drop_marker(&slots, 1, 1, 4.0), None);
// The first tab dropped at the end: after the last tab.
assert_eq!(drop_marker(&slots, 0, 2, 4.0), Some(310.0));
// The first tab dropped between the middle and the last: in the gap between them.
assert_eq!(drop_marker(&slots, 0, 1, 4.0), Some(206.0));
// The last tab dropped at the start: before the first tab.
assert_eq!(drop_marker(&slots, 2, 0, 4.0), Some(-2.0));
assert_eq!(drop_marker(&[], 0, 1, 4.0), None);
}
#[test]
fn the_mac_title_bar_centres_and_clears_the_traffic_lights() {
// The lights are centred in the bar, and content keeps the same gap after them (#789).

View File

@ -260,6 +260,8 @@ impl PdfCraftApp {
}
}
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
"view.next_tab" => self.step_tab(true),
"view.previous_tab" => self.step_tab(false),
"view.theme" => {
let next = if self.theme == ThemeKind::Light { ThemePreference::Dark } else { ThemePreference::Light };
self.set_theme_preference(next);
@ -636,6 +638,20 @@ impl PdfCraftApp {
true
}
/// Shows the next tab (or the previous one) in the strip's order, wrapping around at either
/// end (#1542). The Combine files tab, when open, comes after the documents. From Home the
/// next tab is the first and the previous one the last.
fn step_tab(&mut self, forward: bool) {
let count = self.views.len() + usize::from(self.combine_tab.open);
let current = if self.combine_showing() { Some(self.views.len()) } else { self.active };
let Some(next) = tab_after(current, count, forward) else { return };
if next < self.views.len() {
self.active = Some(next);
} else {
self.open_combine_tab();
}
}
/// Run the registered keyboard shortcuts (more specific combinations first).
pub(crate) fn registry_shortcuts(&mut self, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
@ -685,6 +701,24 @@ impl PdfCraftApp {
}
}
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
/// no tab, forward is the first and back the last. `None` when there are no tabs.
fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usize> {
let last = count.checked_sub(1)?;
Some(match (current.filter(|&i| i <= last), forward) {
(None, true) => 0,
(None, false) => last,
(Some(i), true) => {
if i == last {
0
} else {
i + 1
}
}
(Some(i), false) => i.checked_sub(1).unwrap_or(last),
})
}
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) {
for spec in commands::menu(menu) {
@ -761,4 +795,20 @@ mod tests {
assert_eq!(command_modifier_label(&ctx), "⌘");
crate::i18n::set_current(previous);
}
/// Next and previous tab step one place and wrap around at either end; from no tab they go
/// to the first or the last (#1542).
#[test]
fn tab_steps_wrap_around_and_start_from_either_end() {
assert_eq!(tab_after(Some(0), 3, true), Some(1));
assert_eq!(tab_after(Some(2), 3, true), Some(0), "the last wraps to the first");
assert_eq!(tab_after(Some(1), 3, false), Some(0));
assert_eq!(tab_after(Some(0), 3, false), Some(2), "the first wraps to the last");
assert_eq!(tab_after(None, 3, true), Some(0));
assert_eq!(tab_after(None, 3, false), Some(2));
assert_eq!(tab_after(Some(0), 1, true), Some(0), "a single tab stays");
assert_eq!(tab_after(Some(7), 3, true), Some(0), "a stale index starts over");
assert_eq!(tab_after(None, 0, true), None);
assert_eq!(tab_after(Some(0), 0, false), None);
}
}

View File

@ -80,6 +80,8 @@
Column select تحديد عمود
Full screen mode وضع ملء الشاشة
Read mode وضع القراءة
Next tab علامة التبويب التالية
Previous tab علامة التبويب السابقة
Switch light / dark theme التبديل بين السمة الفاتحة والداكنة
Comments panel لوحة التعليقات
Form fields panel لوحة حقول النموذج

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -116,6 +116,8 @@
Column select Избор на колона
Full screen mode Цял екран
Read mode Режим на четене
Next tab Следващ раздел
Previous tab Предишен раздел
Switch light / dark theme Превключи светла / тъмна тема
Comments panel Панел с коментари
Form fields panel Панел с полета на формуляра

Can't render this file because it contains an unexpected character in line 8 and column 16.

View File

@ -93,6 +93,8 @@
Marquee zoom Zvětšit vybranou oblast
Full screen mode Režim celé obrazovky
Read mode Režim čtení
Next tab Další karta
Previous tab Předchozí karta
Switch light / dark theme Přepnout světlý / tmavý motiv
Comments panel Panel komentářů
Form fields panel Panel formulářových polí

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -51,6 +51,8 @@
Column select Spaltenauswahl
Full screen mode Vollbildmodus
Read mode Lesemodus
Next tab Nächster Tab
Previous tab Vorheriger Tab
Switch light / dark theme Helles / dunkles Design umschalten
Comments panel Kommentarbereich
Form fields panel Formularfeldbereich

Can't render this file because it contains an unexpected character in line 6 and column 51.

View File

@ -65,6 +65,8 @@
Column select Επιλογή στήλης
Full screen mode Λειτουργία πλήρους οθόνης
Read mode Λειτουργία ανάγνωσης
Next tab Επόμενη καρτέλα
Previous tab Προηγούμενη καρτέλα
Switch light / dark theme Εναλλαγή φωτεινού / σκοτεινού θέματος
Comments panel Πίνακας σχολίων
Form fields panel Πίνακας πεδίων φόρμας

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -74,6 +74,8 @@
Column select Selección de columna
Full screen mode Modo de pantalla completa
Read mode Modo de lectura
Next tab Pestaña siguiente
Previous tab Pestaña anterior
Switch light / dark theme Cambiar entre tema claro y oscuro
Comments panel Panel de comentarios
Form fields panel Panel de campos de formulario

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

@ -51,6 +51,8 @@
Column select Sélection de colonne
Full screen mode Mode plein écran
Read mode Mode lecture
Next tab Onglet suivant
Previous tab Onglet précédent
Switch light / dark theme Basculer entre les thèmes clair et sombre
Comments panel Panneau des commentaires
Form fields panel Panneau des champs de formulaire

Can't render this file because it contains an unexpected character in line 6 and column 39.

View File

@ -51,6 +51,8 @@
Column select Oszlopkijelölés
Full screen mode Teljes képernyő
Read mode Olvasási mód
Next tab Következő lap
Previous tab Előző lap
Switch light / dark theme Világos / sötét téma váltása
Comments panel Megjegyzések panel
Form fields panel Űrlapmezők panel

Can't render this file because it contains an unexpected character in line 7 and column 44.

View File

@ -77,6 +77,8 @@
Column select Selezione colonna
Full screen mode Modalità schermo intero
Read mode Modalità lettura
Next tab Scheda successiva
Previous tab Scheda precedente
Switch light / dark theme Alterna tema chiaro/scuro
Comments panel Pannello commenti
Form fields panel Pannello campi modulo

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -80,6 +80,8 @@
Column select 列の選択
Full screen mode 全画面表示
Read mode 閲覧モード
Next tab 次のタブ
Previous tab 前のタブ
Switch light / dark theme 明るい/暗いテーマを切り替え
Comments panel コメントパネル
Form fields panel フォームフィールドパネル

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -54,6 +54,8 @@
Take a snapshot Суретке түсіру
Full screen mode Толық экран режимі
Read mode Оқу режимі
Next tab Келесі қойынды
Previous tab Алдыңғы қойынды
Switch light / dark theme Ашық / қараңғы теманы ауыстыру
Comments panel Түсініктемелер панелі
Form fields panel Форма өрістері панелі

1 Menu Мәзір
54 Take a snapshot Суретке түсіру
55 Full screen mode Толық экран режимі
56 Read mode Оқу режимі
57 Next tab Келесі қойынды
58 Previous tab Алдыңғы қойынды
59 Switch light / dark theme Ашық / қараңғы теманы ауыстыру
60 Comments panel Түсініктемелер панелі
61 Form fields panel Форма өрістері панелі

View File

@ -78,6 +78,8 @@
Default zoom Numatytasis mastelis
Full screen mode Viso ekrano veiksena
Read mode Skaitymo veiksena
Next tab Kita kortelė
Previous tab Ankstesnė kortelė
Comments Komentarai
Comment Komentaras
Add comment Pridėti komentarą

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -76,6 +76,8 @@
Take a snapshot Zrób migawkę
Full screen mode Tryb pełnoekranowy
Read mode Tryb czytania
Next tab Następna karta
Previous tab Poprzednia karta
Switch light / dark theme Przełącz jasny / ciemny motyw
Comments panel Panel komentarzy
Form fields panel Panel pól formularza

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -58,6 +58,8 @@
Column select Seleção de coluna
Full screen mode Modo de tela cheia
Read mode Modo de leitura
Next tab Próxima aba
Previous tab Aba anterior
Switch light / dark theme Alternar tema claro / escuro
Comments panel Painel de comentários
Form fields panel Painel de campos de formulário

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -71,6 +71,8 @@
Column select Выделение столбца
Full screen mode Полноэкранный режим
Read mode Режим чтения
Next tab Следующая вкладка
Previous tab Предыдущая вкладка
Switch light / dark theme Переключить светлую / тёмную тему
Comments panel Панель комментариев
Form fields panel Панель полей формы

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -1393,6 +1393,8 @@
Read Branje
Read Only Samo za branje
Read mode Način branja
Next tab Naslednji zavihek
Previous tab Prejšnji zavihek
Ready Omogočeno
Real name Pravo ime
Reason Razlog

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -57,6 +57,8 @@ comment menu Edit సవరించు
Column select నిలువు వరుస ఎంపిక
Full screen mode పూర్తి స్క్రీన్ మోడ్
Read mode చదివే మోడ్
Next tab తదుపరి ట్యాబ్
Previous tab మునుపటి ట్యాబ్
Switch light / dark theme లేత / ముదురు థీమ్‌ల మధ్య మారు
Comments panel వ్యాఖ్యల ప్యానెల్
Form fields panel ఫారం ఫీల్డ్‌ల ప్యానెల్

Can't render this file because it contains an unexpected character in line 13 and column 16.

View File

@ -42,6 +42,8 @@
Take a snapshot Anlık görüntü al
Full screen mode Tam ekran modu
Read mode Okuma modu
Next tab Sonraki sekme
Previous tab Önceki sekme
Switch light / dark theme Açık / koyu temaya geç
Comments panel Yorumlar paneli
Form fields panel Form alanları paneli

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -78,6 +78,8 @@
Column select Виділення стовпця
Full screen mode Повноекранний режим
Read mode Режим читання
Next tab Наступна вкладка
Previous tab Попередня вкладка
Switch light / dark theme Перемкнути світлу / темну тему
Comments panel Панель коментарів
Form fields panel Панель полів форми

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -122,6 +122,8 @@
Column select 列选择
Full screen mode 全屏模式
Read mode 阅读模式
Next tab 下一个标签页
Previous tab 上一个标签页
Switch light / dark theme 切换浅色/深色主题
Comments panel 注释面板
Form fields panel 表单域面板

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -81,6 +81,8 @@
Column select 欄選取
Full screen mode 全螢幕模式
Read mode 閱讀模式
Next tab 下一個分頁
Previous tab 上一個分頁
Switch light / dark theme 切換淺色/深色主題
Comments panel 註解面板
Form fields panel 表單欄位面板

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -1179,11 +1179,15 @@ impl PdfCraftApp {
open_worker::ResultKind::Failed(reason) => {
self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]);
}
open_worker::ResultKind::Conversion(bytes) => {
if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]);
open_worker::ResultKind::Conversion(bytes) => match self.open_converted(&pending.name, &bytes) {
Some(Ok(())) => {
if let Some(command) = pending.then {
self.execute(command);
}
}
}
Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]),
None => {}
},
open_worker::ResultKind::Prepared { bytes, result } => match *result {
Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) {
Ok(id) => {
@ -1686,6 +1690,12 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path(&mut self, path: &str) {
self.open_path_then(path, None);
}
/// Keep a chosen tool with its file until preparation and any password entry finish.
#[cfg(not(target_arch = "wasm32"))]
fn open_path_then(&mut self, path: &str, then: Option<&'static str>) {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
// Taken before reading: a build that rewrites the file meanwhile still counts as a change.
let stamp = file_watch::Stamp::at(path);
@ -1698,10 +1708,15 @@ impl PdfCraftApp {
let before = self.views.len();
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
} else if self.views.len() > before
&& let Some(doc) = self.views.last().map(|view| view.id)
{
self.watch.known_as(doc, stamp);
} else if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
prompt.then = then;
} else if self.views.len() > before {
if let Some(doc) = self.views.last().map(|view| view.id) {
self.watch.known_as(doc, stamp);
}
if let Some(command) = then {
self.execute(command);
}
}
}
Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]),
@ -1718,7 +1733,7 @@ impl PdfCraftApp {
activate: true,
after_open: None,
recovered: None,
then: None,
then,
}) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
}
@ -1785,6 +1800,20 @@ impl PdfCraftApp {
};
}
/// Moves the tab at `from` so it sits at `to` (#1537). The same document stays active
/// wherever it lands; an index out of range changes nothing.
pub fn move_tab(&mut self, from: usize, to: usize) {
if from >= self.views.len() || to >= self.views.len() || from == to {
return;
}
let active = self.active_ids().map(|(_, id)| id);
let view = self.views.remove(from);
self.views.insert(to, view);
if let Some(id) = active {
self.active = self.views.iter().position(|v| v.id == id);
}
}
pub fn active_ids(&self) -> Option<(usize, DocId)> {
self.active.and_then(|i| self.views.get(i).map(|v| (i, v.id)))
}
@ -1972,14 +2001,7 @@ impl PdfCraftApp {
let command = spec.id;
self.ask_one(pickers::Ask::File(dialog), None, move |app, path| {
let path = path.to_string_lossy().into_owned();
let before = app.views.len();
app.open_path(&path);
if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) {
// Encrypted: the tool runs once the password is in.
prompt.then = Some(command);
} else if app.views.len() > before && app.active.is_some() {
app.execute(command);
}
app.open_path_then(&path, Some(command));
});
}
@ -2806,7 +2828,12 @@ impl eframe::App for PdfCraftApp {
}
// Don't replace a pending prompt or import. A startup file may need its own password
// prompt, so its bytes stay queued until the current interaction finishes.
if self.password_prompt.is_none()
#[cfg(not(target_arch = "wasm32"))]
let opening = !self.pending_opens.is_empty();
#[cfg(target_arch = "wasm32")]
let opening = false;
if !opening
&& self.password_prompt.is_none()
&& self.image_import.is_none()
&& self.dialog.is_none()
&& self.close_request.is_none()

View File

@ -88,3 +88,321 @@ fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() {
assert_eq!(app.active, Some(1));
let _ = std::fs::remove_file(path);
}
struct ToolOpenFile(std::path::PathBuf);
impl Drop for ToolOpenFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[allow(clippy::expect_used)]
fn tool_open_file(case: &str, extension: &str, bytes: &[u8]) -> ToolOpenFile {
let path = std::env::temp_dir().join(format!("pdfcraft-open-tool-{}-{case}.{extension}", std::process::id()));
std::fs::write(&path, bytes).expect("write the synthetic tool input");
ToolOpenFile(path)
}
fn tool_open_harness() -> Harness<'static, PdfCraftApp> {
let (mut harness, _) = harness();
harness.state_mut().close_tab(0);
harness.run_steps(2);
harness
}
struct HeldToolOpen {
started: std::sync::mpsc::Receiver<()>,
release: std::sync::mpsc::Sender<()>,
finished: std::sync::mpsc::Receiver<()>,
}
impl HeldToolOpen {
fn install(harness: &mut Harness<'static, PdfCraftApp>) -> Self {
let (gate, started, release, finished) = open_worker::prepare_gate();
harness.state_mut().open_prepare_gate = Some(gate);
Self { started, release, finished }
}
#[allow(clippy::expect_used)]
fn wait_started(&self) {
self.started.recv_timeout(Duration::from_secs(5)).expect("the native open reached its preparation barrier");
}
#[allow(clippy::expect_used)]
fn complete(&self, harness: &mut Harness<'static, PdfCraftApp>) {
self.release.send(()).expect("release the native preparation");
self.finished.recv_timeout(Duration::from_secs(5)).expect("the native open returned its result");
harness.run_steps(2);
}
}
fn choose_export_tool(harness: &mut Harness<'static, PdfCraftApp>, file: &ToolOpenFile) {
harness.state_mut().pick_override = Some(vec![file.0.to_string_lossy().into_owned()]);
harness.state_mut().run_command("export.text");
harness.run_steps(2);
}
/// Picking a file for a tool must carry the tool through native preparation; another file
/// opened while it is pending must not become the tool's source.
#[allow(clippy::expect_used)]
fn chosen_tool_runs_after_preparation(extension: &str, bytes: Vec<u8>) {
let mut harness = tool_open_harness();
let held = HeldToolOpen::install(&mut harness);
let file = tool_open_file("pending", extension, &bytes);
choose_export_tool(&mut harness, &file);
held.wait_started();
assert!(harness.state().views.is_empty());
assert!(harness.state().dialog.is_none(), "the tool cannot run before its document exists");
harness.state_mut().open_bytes("other.pdf", None, fixture(3)).expect("another document opens while preparation is held");
let other = harness.state().active_ids().expect("the other document is active").1;
held.complete(&mut harness);
let app = harness.state();
let source = app.active_ids().expect("the chosen file becomes active").1;
assert_ne!(source, other);
assert_eq!(app.views.len(), 2, "the chosen file commits once and retains the other document");
assert_eq!(app.session.get(other).expect("the other document is retained").info.pages.len(), 3);
assert!(!app.session.get(other).expect("the other document is retained").dirty);
let chosen = app.session.get(source).expect("the tool's source exists");
if extension == "pdf" {
assert_eq!(chosen.path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
assert_eq!(chosen.info.pages.len(), 2);
assert_eq!(chosen.bytes.as_slice(), bytes.as_slice(), "the selected PDF's contents are retained");
} else {
assert!(chosen.dirty && chosen.path.is_none(), "conversion produces a new unsaved PDF");
assert_eq!(chosen.info.pages.len(), 1);
let mut renderer = pdfcraft_render::PageRenderer::new(chosen.bytes.clone(), Default::default());
let output = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, ..Default::default() });
let text = output.text.expect("read the converted text").plain_text();
assert_eq!(text.split_whitespace().collect::<Vec<_>>().join(" "), std::str::from_utf8(&bytes).expect("synthetic input is UTF-8"));
}
assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the chosen Export tool runs after its source commits");
assert!(app.pending_opens.is_empty());
}
#[test]
fn a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits() {
chosen_tool_runs_after_preparation("pdf", fixture(2));
}
#[test]
fn a_chosen_tool_runs_on_converted_text_only_after_native_preparation_commits() {
chosen_tool_runs_after_preparation("txt", b"Synthetic text input for the selected export tool".to_vec());
}
#[allow(clippy::expect_used)]
fn encrypted_tool_fixture() -> Vec<u8> {
let mut session = pdfcraft_engine::Session::new();
let id = session.open("secret.pdf", None, Arc::new(fixture(2)), None).expect("open the synthetic PDF");
session
.apply(id, pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { open_password: Some("tool-password".into()), ..Default::default() }))
.expect("encrypt the synthetic PDF");
session.save_bytes(id).expect("write the encrypted synthetic PDF").as_ref().clone()
}
#[test]
#[allow(clippy::expect_used)]
fn a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock() {
let mut harness = tool_open_harness();
let held = HeldToolOpen::install(&mut harness);
let file = tool_open_file("encrypted-retry", "pdf", &encrypted_tool_fixture());
choose_export_tool(&mut harness, &file);
held.wait_started();
held.complete(&mut harness);
assert!(harness.state().password_prompt.is_some());
assert!(harness.state().views.is_empty() && harness.state().dialog.is_none());
harness.state_mut().submit_password(Some("wrong-password".into()));
held.wait_started();
assert!(harness.state().password_prompt.is_none(), "the submitted password is still being checked");
held.complete(&mut harness);
let prompt = harness.state().password_prompt.as_ref().expect("the wrong password is reported");
assert!(prompt.error.is_some());
assert_eq!(prompt.path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
assert!(harness.state().views.is_empty() && harness.state().dialog.is_none());
harness.state_mut().submit_password(Some("tool-password".into()));
held.wait_started();
assert!(harness.state().dialog.is_none(), "the tool waits for authentication to succeed");
held.complete(&mut harness);
let app = harness.state();
assert!(app.password_prompt.is_none() && app.pending_opens.is_empty());
assert_eq!(app.views.len(), 1);
let source = app.active_ids().expect("the unlocked document is active").1;
assert_eq!(app.session.get(source).expect("the unlocked document exists").path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the chosen tool survives the wrong-password retry");
}
#[test]
#[allow(clippy::expect_used)]
fn cancelling_a_chosen_tools_picker_or_password_never_runs_it_on_another_document() {
let mut harness = tool_open_harness();
harness.state_mut().pick_override = Some(Vec::new());
harness.state_mut().run_command("export.text");
harness.run_steps(2);
assert!(harness.state().pending_opens.is_empty());
assert!(harness.state().views.is_empty() && harness.state().dialog.is_none());
let held = HeldToolOpen::install(&mut harness);
let file = tool_open_file("encrypted-cancel", "pdf", &encrypted_tool_fixture());
choose_export_tool(&mut harness, &file);
held.wait_started();
held.complete(&mut harness);
assert!(harness.state().password_prompt.is_some());
harness.state_mut().submit_password(None);
harness.state_mut().open_bytes("other.pdf", None, fixture(3)).expect("a different document opens after cancellation");
harness.run_steps(3);
assert!(harness.state().password_prompt.is_none() && harness.state().pending_opens.is_empty());
assert_eq!(harness.state().views.len(), 1);
let id = harness.state().active_ids().expect("the other document is active").1;
assert_eq!(harness.state().session.get(id).expect("the other document exists").name, "other.pdf");
assert!(harness.state().dialog.is_none(), "cancelling discards the chosen tool's continuation");
}
#[test]
fn a_chosen_tool_does_not_run_when_native_preparation_rejects_the_file() {
let mut harness = tool_open_harness();
let held = HeldToolOpen::install(&mut harness);
let file = tool_open_file("invalid", "pdf", b"%PDF-1.7\nnot a PDF object graph");
choose_export_tool(&mut harness, &file);
held.wait_started();
held.complete(&mut harness);
assert!(harness.state().views.is_empty() && harness.state().pending_opens.is_empty());
assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none());
assert!(harness.state().toast.as_ref().is_some_and(|(message, _)| message.contains("Couldn't open")), "the open failure is reported");
}
#[test]
#[allow(clippy::expect_used)]
fn startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt() {
let (mut harness, _) = harness();
let existing = harness.state().active_ids().expect("the existing document is active").1;
let original = harness.state().session.get(existing).expect("the existing document is open").bytes.clone();
let encrypted = encrypted_tool_fixture();
harness.state_mut().open_bytes("local-secret.pdf", None, encrypted.clone()).expect("the local PDF asks for its password");
let held = HeldToolOpen::install(&mut harness);
harness.state_mut().submit_password(Some("tool-password".into()));
held.wait_started();
harness.state().startup_inbox.lock().expect("startup inbox").push(("startup-secret.pdf".into(), encrypted.clone()));
harness.run_steps(2);
assert_eq!(harness.state().startup_inbox.lock().expect("startup inbox").len(), 1, "startup bytes stay queued during local authentication");
assert!(harness.state().password_prompt.is_none(), "no unrelated prompt is created during the held open");
assert_eq!(harness.state().active_ids().map(|(_, id)| id), Some(existing));
held.complete(&mut harness);
let local = harness.state().active_ids().expect("the authenticated local document is active").1;
assert_ne!(local, existing);
assert_eq!(harness.state().session.get(local).expect("the local document is open").name, "local-secret.pdf");
assert_eq!(harness.state().views.len(), 2);
assert!(harness.state().startup_inbox.lock().expect("startup inbox").is_empty());
let prompt = harness.state().password_prompt.as_ref().expect("the startup PDF retains its own password prompt after local completion");
assert_eq!(prompt.name, "startup-secret.pdf");
assert_eq!(prompt.bytes.as_slice(), encrypted.as_slice());
assert!(!prompt.activate, "unlocking a late startup PDF must keep it in the background");
harness.state_mut().submit_password(Some("tool-password".into()));
held.wait_started();
assert_eq!(harness.state().active_ids().map(|(_, id)| id), Some(local));
held.complete(&mut harness);
let app = harness.state();
assert!(app.password_prompt.is_none() && app.pending_opens.is_empty());
assert_eq!(app.views.len(), 3);
assert_eq!(app.active_ids().map(|(_, id)| id), Some(local), "startup authentication does not steal the local document's focus");
let startup = app.session.docs().iter().find(|doc| doc.name == "startup-secret.pdf").expect("the startup document is available");
assert_ne!(startup.id, local);
assert_eq!(startup.info.pages.len(), 2);
assert_eq!(app.session.get(existing).expect("the existing document remains open").bytes, original);
}
#[test]
#[allow(clippy::expect_used)]
fn startup_intake_waits_through_wrong_password_retry_and_cancel_preserves_the_existing_document() {
let (mut harness, _) = harness();
let existing = harness.state().active_ids().expect("the existing document is active").1;
let original = harness.state().session.get(existing).expect("the existing document is open").bytes.clone();
harness.state_mut().open_bytes("local-secret.pdf", None, encrypted_tool_fixture()).expect("the local PDF asks for its password");
let held = HeldToolOpen::install(&mut harness);
harness.state_mut().submit_password(Some("wrong-password".into()));
held.wait_started();
harness.state().startup_inbox.lock().expect("startup inbox").push(("startup.pdf".into(), fixture(1)));
harness.run_steps(2);
assert_eq!(harness.state().startup_inbox.lock().expect("startup inbox").len(), 1, "startup bytes wait while the password is checked");
assert_eq!(harness.state().active_ids().map(|(_, id)| id), Some(existing));
held.complete(&mut harness);
let prompt = harness.state().password_prompt.as_ref().expect("the local wrong-password prompt is retained");
assert_eq!(prompt.name, "local-secret.pdf");
assert!(prompt.error.is_some());
assert_eq!(harness.state().startup_inbox.lock().expect("startup inbox").len(), 1, "startup bytes also wait for the user's retry or cancellation");
assert_eq!(harness.state().views.len(), 1);
harness.state_mut().submit_password(None);
harness.run_steps(2);
let app = harness.state();
assert!(app.password_prompt.is_none() && app.pending_opens.is_empty());
assert!(app.startup_inbox.lock().expect("startup inbox").is_empty());
assert_eq!(app.views.len(), 2);
assert_eq!(app.active_ids().map(|(_, id)| id), Some(existing), "cancelling the local open leaves the startup PDF in the background");
assert!(!app.session.docs().iter().any(|doc| doc.name == "local-secret.pdf"));
assert_eq!(app.session.docs().iter().find(|doc| doc.name == "startup.pdf").expect("the startup document is available").info.pages.len(), 1);
assert_eq!(app.session.get(existing).expect("the existing document remains open").bytes, original);
}
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
fn assert_tool_runs_after_background_open(extension: &str, bytes: Vec<u8>, pages: usize) {
let (mut harness, _client) = harness();
harness.state_mut().close_tab(0);
let (gate, started, release, finished) = open_worker::prepare_gate();
harness.state_mut().open_prepare_gate = Some(gate);
let path = std::env::temp_dir().join(format!("pdfcraft-tool-worker-{}.{extension}", std::process::id()));
std::fs::write(&path, &bytes).expect("write picked document");
harness.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
harness.state_mut().run_command("export.text");
harness.step();
started.recv_timeout(Duration::from_secs(5)).expect("the picked document reached preparation");
assert!(harness.state().active.is_none());
assert!(harness.state().dialog.is_none(), "the tool waits for its document");
release.send(()).expect("release picked document preparation");
finished.recv_timeout(Duration::from_secs(5)).expect("picked document prepared");
harness.step();
let app = harness.state();
assert_eq!(app.views.len(), 1, "the chosen document opens once");
assert_eq!(app.session.get(app.views[0].id).expect("chosen document").info.pages.len(), pages);
assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the chosen tool runs after preparation: {:?}", app.dialog);
harness.run_steps(2);
assert_eq!(harness.state().views.len(), 1);
assert_eq!(std::fs::read(&path).expect("read unchanged input"), bytes);
let _ = std::fs::remove_file(path);
}
#[test]
fn tool_from_home_runs_after_pdf_preparation() {
assert_tool_runs_after_background_open("pdf", fixture(2), 2);
}
#[test]
fn tool_from_home_runs_after_text_conversion() {
assert_tool_runs_after_background_open("txt", b"Synthetic document for a chosen tool".to_vec(), 1);
}
#[test]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
fn a_startup_document_waits_for_an_active_document_to_prepare() {
let (mut harness, _client) = harness();
harness.state_mut().close_tab(0);
let (gate, started, release, finished) = open_worker::prepare_gate();
harness.state_mut().open_prepare_gate = Some(gate);
let path = std::env::temp_dir().join(format!("pdfcraft-startup-worker-{}.pdf", std::process::id()));
std::fs::write(&path, fixture(2)).expect("write chosen document");
harness.state_mut().open_path(&path.to_string_lossy());
started.recv_timeout(Duration::from_secs(5)).expect("chosen document reached preparation");
harness.state().startup_inbox.lock().expect("startup inbox").push(("startup.pdf".into(), fixture(1)));
harness.step();
assert_eq!(harness.state().startup_inbox.lock().expect("startup inbox").len(), 1, "startup bytes wait for the pending active open");
assert!(harness.state().views.is_empty());
release.send(()).expect("release chosen document preparation");
finished.recv_timeout(Duration::from_secs(5)).expect("chosen document prepared");
harness.step();
assert_eq!(harness.state().views.len(), 2);
assert_eq!(harness.state().active, Some(0), "the chosen document keeps focus");
let _ = std::fs::remove_file(path);
}

View File

@ -178,7 +178,7 @@ impl PrintDraft {
}
/// Lists the printers off the UI thread (the desktop app sets [`PdfCraftApp::printer_source`]):
/// on Windows that takes PowerShell a second or more, and CUPS' driverless discovery can wait on
/// on Windows each driver's capabilities are read for its two-sided support, and CUPS' driverless discovery can wait on
/// the network. Without a source the dialog reads them as it opens, so tests stay deterministic.
pub type PrinterSource = std::sync::Arc<dyn Fn() -> Vec<spool::Printer> + Send + Sync>;

View File

@ -1,6 +1,8 @@
//! The command registry drives menus, shortcuts and the palette: every registered command must
//! be implemented, disabled commands must say why, and every surface must reach the same action.
mod support;
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
@ -184,9 +186,13 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() {
h.run_steps(2);
h.state_mut().run_command("export.text");
assert!(h.state().toast.as_ref().is_none_or(|t| t.0 != "Open a document first"));
h.run_steps(3);
support::wait_until(&mut h, "picked document and chosen tool", |app| app.active.is_some() && matches!(app.dialog, Some(Dialog::Export(_))));
assert!(h.state().active.is_some(), "the picked file is open");
assert!(matches!(h.state().dialog, Some(Dialog::Export(_))), "and the tool runs on it: {:?}", h.state().dialog);
let (_, id) = h.state().active_ids().unwrap();
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.name, "picked.pdf");
assert_eq!(doc.bytes.as_slice(), fixture(2).as_slice());
// Cancelled: nothing opens and nothing runs.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
@ -286,12 +292,16 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
h.run_steps(3);
h.get_by_label("Open Recent ⏵").hover();
h.run_steps(3);
let original = h.state().views[0].id;
h.get_by_label_contains("recent.pdf").click();
h.run_steps(4);
support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none());
let app = h.state();
assert_eq!(app.views.len(), 2, "the recent file opened in a new tab");
let active = app.active.unwrap();
assert_eq!(app.session.get(app.views[active].id).and_then(|d| d.path.as_deref()), Some(path.as_str()), "the active tab is the recent file");
assert_eq!(app.session.get(app.views[active].id).unwrap().bytes.as_slice(), fixture(2).as_slice());
assert!(app.views.iter().any(|view| view.id == original));
assert_eq!(app.session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice());
}
#[test]
@ -580,3 +590,45 @@ fn palette_ime_outside_click_closes_without_running_a_command() {
assert!(h.state().palette_query.is_empty());
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].rotation, 0);
}
/// Ctrl+Tab and Ctrl+Shift+Tab step through the tabs in the strip's order, wrap around at either
/// end and include the Combine files tab after the documents (#1542). A Windows or Linux Ctrl
/// press is both `ctrl` and `command`.
#[test]
fn ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around() {
let mut h = harness();
h.state_mut().open_bytes("second.pdf", None, fixture(1)).unwrap();
h.state_mut().open_bytes("third.pdf", None, fixture(1)).unwrap();
h.run_steps(3);
let ctrl = Modifiers::CTRL | Modifiers::COMMAND;
let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers| {
h.key_press_modifiers(modifiers, Key::Tab);
h.run_steps(2);
h.state().active
};
assert_eq!(h.state().active, Some(2));
assert_eq!(press(&mut h, ctrl), Some(0), "the last tab wraps to the first");
assert_eq!(press(&mut h, ctrl), Some(1));
assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), Some(0));
assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), Some(2), "the first tab wraps to the last");
// The order is the one on screen, after the tabs are moved.
let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
h.state_mut().move_tab(2, 0);
h.run_steps(2);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2]));
press(&mut h, ctrl);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[0]));
// Combine files comes after the documents.
h.state_mut().execute("page.combine");
h.run_steps(2);
assert!(h.state().combine_showing());
assert_eq!(press(&mut h, ctrl), Some(0), "Combine files wraps to the first document");
assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), None);
assert!(h.state().combine_showing(), "back from the first document is Combine files");
// Plain Tab still belongs to keyboard focus, not to the tabs.
press(&mut h, Modifiers::NONE);
assert!(h.state().combine_showing());
for doc in h.state().session.docs() {
assert!(!doc.dirty, "switching tabs changes no document");
}
}

View File

@ -1,6 +1,8 @@
//! Addresses a document asks to open (links, button URI actions, `app.launchURL`) wait for the
//! user's permission, and anything but a web or email address is refused (#90, #91).
mod support;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{LinkOrigin, PdfCraftApp, PendingLink};
@ -175,6 +177,7 @@ fn a_local_gotor_link_opens_in_a_tab_and_applies_its_destination() {
std::fs::write(dir.join("target.pdf"), linked_pages(2, None)).unwrap();
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /FitH 200]");
click_remote_link(&mut h);
support::wait_until(&mut h, "remote document", |app| app.views.len() == 2);
assert_eq!(h.state().views.len(), 2);
assert_eq!(h.state().active, Some(1));
assert_eq!(h.state().views[1].current, 1, "the remote page destination is applied");
@ -191,16 +194,24 @@ fn a_remote_destination_survives_password_retries_and_cancel_opens_no_tab() {
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
click_remote_link(&mut h);
support::wait_until(&mut h, "remote password prompt", |app| app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf"));
assert!(h.state().password_prompt.is_some());
assert_eq!(h.state().views.len(), 1);
h.state_mut().submit_password(Some("wrong-password".into()));
support::wait_until(&mut h, "wrong-password reply", |app| {
app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
});
assert!(h.state().password_prompt.as_ref().is_some_and(|p| p.error.is_some()));
h.state_mut().submit_password(Some("test-password".into()));
support::wait_until(&mut h, "authenticated remote document", |app| app.views.len() == 2 && app.password_prompt.is_none());
assert_eq!(h.state().views.len(), 2);
assert_eq!(h.state().views[1].current, 1, "the destination survives a wrong-password retry");
let mut cancelled = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
click_remote_link(&mut cancelled);
support::wait_until(&mut cancelled, "cancelled target password prompt", |app| {
app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf")
});
cancelled.state_mut().submit_password(None);
assert_eq!(cancelled.state().views.len(), 1, "cancelling does not leave a half-open target tab");
}
@ -413,17 +424,21 @@ fn an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs() {
assert!(h.state().toast.is_none(), "the nonexistent target is not accessed before consent");
assert!(h.state().password_prompt.is_none());
// The target only appears AFTER the confirmation is displayed.
std::fs::write(&target, linked_pages(2, None)).unwrap();
let target_bytes = linked_pages(2, None);
std::fs::write(&target, &target_bytes).unwrap();
h.state_mut().open_bytes("other.pdf", None, fixture()).unwrap();
assert_eq!(h.state().active, Some(1));
h.run_steps(3);
h.get_by_label("Allow").click();
h.run_steps(3);
support::wait_until(&mut h, "approved absolute target", |app| app.views.len() == 3);
assert!(h.state().pending_remote_link.is_none());
assert_eq!(h.state().views.len(), 3);
assert_eq!(h.state().views[2].current, 1);
assert_eq!(h.state().views[2].fit, pdfcraft_ui_egui::canvas::Fit::Width);
assert_eq!(h.state().active, Some(2));
let target_doc = h.state().session.get(h.state().views[2].id).unwrap();
assert_eq!(target_doc.path.as_deref(), Some(file));
assert_eq!(target_doc.bytes.as_slice(), target_bytes.as_slice());
assert!(!h.state().session.get(source).unwrap().dirty);
assert_eq!(h.state().last_opened_url, None);
std::fs::remove_dir_all(dir).unwrap();
@ -470,25 +485,43 @@ fn consenting_to_absolute_gotor_still_validates_the_target() {
std::fs::write(dir.join("notes.txt"), b"text").unwrap();
std::fs::write(dir.join("broken.pdf"), b"not a PDF").unwrap();
#[cfg_attr(not(unix), allow(unused_mut))]
let mut cases = vec!["missing.pdf", "folder.pdf", "notes.txt", "broken.pdf"];
let mut cases = vec![
("missing.pdf", "Couldn't follow linked PDF: the linked file couldn't be read:"),
("folder.pdf", "Couldn't follow linked PDF: the linked path is not a regular file"),
("notes.txt", "Couldn't follow linked PDF: the linked file is not a .pdf file"),
("broken.pdf", "Couldn't open broken.pdf: the file is not a readable PDF:"),
];
#[cfg(unix)]
{
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
cases.push("zero.pdf");
cases.push(("zero.pdf", "Couldn't follow linked PDF: the linked path is not a regular file"));
std::fs::File::create(dir.join("large.pdf")).unwrap().set_len(pdfcraft_engine::MAX_REMOTE_DOCUMENT_BYTES + 1).unwrap();
cases.push("large.pdf");
cases.push(("large.pdf", "Couldn't follow linked PDF: the linked file is too large to open"));
}
for name in cases {
for (name, expected_error) in cases {
let target = dir.join(name);
let mut h = remote_harness(None, target.to_str().unwrap(), "[0 /Fit]");
let source = h.state().views[0].id;
let before = h.state().session.get(source).unwrap().bytes.clone();
click_remote_link(&mut h);
assert!(h.state().pending_remote_link.is_some());
assert!(h.state().toast.is_none(), "{name} isn't checked before consent");
h.get_by_label("Allow").click();
h.run_steps(3);
support::wait_until(&mut h, "approved invalid target refusal", |app| {
app.toast.as_ref().is_some_and(|(message, _)| message.starts_with(expected_error))
});
assert!(h.state().pending_remote_link.is_none());
assert_eq!(h.state().views.len(), 1, "{name}");
assert!(h.state().toast.is_some(), "{name} is refused after consent");
assert_eq!(h.state().session.docs().len(), 1);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(source));
assert_eq!(h.state().views[0].current, 0);
let doc = h.state().session.get(source).unwrap();
assert_eq!(doc.bytes, before);
assert!(!doc.dirty);
assert!(doc.can_undo().is_none());
assert!(h.state().password_prompt.is_none());
assert!(h.state().last_opened_url.is_none());
}
std::fs::remove_dir_all(dir).unwrap();
}
@ -506,16 +539,23 @@ fn an_approved_absolute_gotor_preserves_password_retries_and_cancellation() {
click_remote_link(&mut h);
assert!(h.state().password_prompt.is_none());
h.get_by_label("Allow").click();
h.run_steps(3);
support::wait_until(&mut h, "approved encrypted target prompt", |app| {
app.password_prompt.as_ref().is_some_and(|p| p.name == "encrypted.pdf")
});
assert!(h.state().password_prompt.is_some());
h.state_mut().submit_password(Some("wrong-password".into()));
support::wait_until(&mut h, "absolute target wrong-password reply", |app| {
app.password_prompt.as_ref().is_some_and(|p| p.name == "encrypted.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
});
assert!(h.state().password_prompt.as_ref().unwrap().error.is_some());
assert_eq!(h.state().views.len(), 1);
if cancel {
h.state_mut().submit_password(None);
assert!(h.state().password_prompt.is_none());
assert_eq!(h.state().views.len(), 1);
} else {
h.state_mut().submit_password(Some("test-password".into()));
support::wait_until(&mut h, "authenticated absolute target", |app| app.password_prompt.is_none() && app.views.len() == 2);
assert_eq!(h.state().views.len(), 2);
assert_eq!(h.state().views[1].current, 1);
}

View File

@ -1,6 +1,8 @@
//! Headless UI tests for editing and saving: the organize toolbar, selection, undo/redo keys,
//! save/save-as, the unsaved-changes prompt and editable document properties.
mod support;
use egui::accesskit::Role;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
@ -1670,10 +1672,18 @@ fn files_dropped_before_the_pointer_is_known_wait_for_it() {
#[test]
fn files_dropped_outside_the_page_grid_still_open_as_documents() {
let mut h = harness(2, |_| {});
let original = h.state().views[0].id;
drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]);
h.run_steps(3);
support::wait_until(&mut h, "dropped document", |app| app.views.len() == 2 && app.password_prompt.is_none());
assert_eq!(h.state().views.len(), 2);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
assert!(h.state().views.iter().any(|view| view.id == original));
assert_eq!(h.state().session.get(original).unwrap().bytes.as_slice(), fixture(2).as_slice());
let (_, id) = h.state().active_ids().unwrap();
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.name, "other.pdf");
assert_eq!(doc.info.pages.len(), 1);
assert_eq!(doc.bytes.as_slice(), fixture(1).as_slice());
}
fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 {
@ -1907,7 +1917,7 @@ fn password_prompt_opens_and_security_tab_reports_the_details() {
field.type_text("pw");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(4);
support::wait_until(&mut h, "unlocked document", |app| app.views.len() == 1);
assert_eq!(h.state().views.len(), 1);
h.state_mut().set_option("dialog", "properties").unwrap();
h.run_steps(2);

View File

@ -4,6 +4,8 @@
//! used on a later frame. `pick_override` stands in for the native panel and, like it, answers
//! on a later frame.
mod support;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_render::PageRenderer;
@ -56,7 +58,7 @@ fn harness(docs: &[usize]) -> Harness<'static, PdfCraftApp> {
}
#[test]
fn file_open_returns_at_once_and_opens_the_pick_on_the_next_frame() {
fn file_open_returns_at_once_and_opens_the_pick_when_preparation_finishes() {
let dir = std::env::temp_dir().join(format!("printcraft-pickers-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("picked.pdf");
@ -66,9 +68,13 @@ fn file_open_returns_at_once_and_opens_the_pick_on_the_next_frame() {
h.state_mut().pick_override = Some(vec![file.to_string_lossy().into_owned()]);
assert!(h.state_mut().execute("file.open"));
assert!(h.state().views.is_empty(), "the command must not wait for the picker");
h.run_steps(2);
support::wait_until(&mut h, "picked document", |app| app.views.len() == 1 && app.password_prompt.is_none());
assert_eq!(h.state().views.len(), 1, "the picked file opens on a later frame");
assert_eq!(pages(h.state(), 0), 2);
let (_, id) = h.state().active_ids().unwrap();
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.path.as_deref(), Some(file.to_string_lossy().as_ref()));
assert_eq!(doc.bytes.as_slice(), fixture(2).as_slice());
let _ = std::fs::remove_dir_all(&dir);
}
@ -621,17 +627,37 @@ fn encrypted_fixture() -> Vec<u8> {
fn a_startup_url_does_not_replace_another_documents_password_prompt() {
for cancel in [false, true] {
let mut h = harness(&[]);
h.state_mut().open_bytes("local-encrypted.pdf", None, encrypted_fixture()).unwrap();
deliver_startup(&h, "late.pdf", fixture(2));
let local_bytes = encrypted_fixture();
let late_bytes = fixture(2);
h.state_mut().open_bytes("local-encrypted.pdf", None, local_bytes.clone()).unwrap();
deliver_startup(&h, "late.pdf", late_bytes.clone());
h.run_steps(2);
assert_eq!(h.state().password_prompt.as_ref().unwrap().name, "local-encrypted.pdf");
assert!(h.state().views.is_empty());
assert_eq!(h.state().startup_inbox.lock().unwrap().len(), 1, "startup bytes wait for the prompt");
h.state_mut().submit_password((!cancel).then(|| "test-password".into()));
h.run_steps(2);
support::wait_until(&mut h, "local password completion and startup open", |app| {
app.password_prompt.is_none() && app.views.len() == if cancel { 1 } else { 2 }
});
assert!(h.state().password_prompt.is_none());
assert!(h.state().startup_inbox.lock().unwrap().is_empty());
assert_eq!(h.state().active, if cancel { None } else { Some(0) });
let app = h.state();
let late: Vec<_> = app.session.docs().iter().filter(|doc| doc.name == "late.pdf").collect();
assert_eq!(late.len(), 1);
assert_eq!(late[0].bytes.as_slice(), late_bytes.as_slice());
assert_eq!(late[0].info.pages.len(), 2);
if cancel {
assert_eq!(app.active, None);
assert_eq!(app.session.docs().len(), 1);
assert!(!app.session.docs().iter().any(|doc| doc.name == "local-encrypted.pdf"));
} else {
let (_, id) = app.active_ids().expect("the authenticated local document is active");
let local = app.session.get(id).unwrap();
assert_eq!(local.name, "local-encrypted.pdf");
assert_eq!(local.bytes.as_slice(), local_bytes.as_slice());
assert_eq!(local.info.pages.len(), 1);
assert_eq!(app.session.docs().len(), 2);
}
assert_eq!(h.state().views.len(), if cancel { 1 } else { 2 });
}
}
@ -639,19 +665,27 @@ fn a_startup_url_does_not_replace_another_documents_password_prompt() {
#[test]
fn unlocking_a_late_startup_url_keeps_it_in_the_background() {
let mut h = harness(&[3]);
deliver_startup(&h, "late-encrypted.pdf", encrypted_fixture());
let original = h.state().views[0].id;
let late_bytes = encrypted_fixture();
deliver_startup(&h, "late-encrypted.pdf", late_bytes.clone());
h.run_steps(2);
assert_eq!(h.state().active, Some(0));
assert_eq!(h.state().password_prompt.as_ref().unwrap().name, "late-encrypted.pdf");
h.state_mut().submit_password(Some("wrong-password".into()));
h.run_steps(2);
support::wait_until(&mut h, "startup wrong-password reply", |app| {
app.password_prompt.as_ref().is_some_and(|p| p.name == "late-encrypted.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
});
assert!(h.state().password_prompt.as_ref().unwrap().error.is_some());
assert_eq!(h.state().active, Some(0));
h.state_mut().submit_password(Some("test-password".into()));
h.run_steps(2);
support::wait_until(&mut h, "unlocked startup document", |app| app.password_prompt.is_none() && app.views.len() == 2);
assert!(h.state().password_prompt.is_none());
assert_eq!(h.state().active, Some(0));
assert_eq!(h.state().views.len(), 2);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(original));
assert_eq!(h.state().session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice());
let late = h.state().session.docs().iter().find(|doc| doc.name == "late-encrypted.pdf").unwrap();
assert_eq!(late.bytes.as_slice(), late_bytes.as_slice());
h.get_by_label("late-encrypted.pdf").click();
h.run_steps(2);
assert_eq!(h.state().active, Some(1));

View File

@ -1,10 +1,12 @@
//! Autosave and crash recovery: a session is "crashed" by dropping the app without a clean
//! quit, and a new session must offer, recover or discard what was autosaved.
mod support;
use egui::accesskit::Role;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_engine::Edit;
use pdfcraft_ui_egui::{PdfCraftApp, RecoveryStore};
@ -80,7 +82,7 @@ fn a_crashed_session_is_offered_and_recovered_unsaved() {
h.get_by_label("Recover unsaved documents?");
h.get_by_label("work.pdf");
h.get_by_label("Recover").click();
h.run_steps(4);
support::wait_until(&mut h, "recovered document", |app| app.views.len() == 1);
let app = h.state();
assert_eq!(app.views.len(), 1);
let doc = app.session.get(app.views[0].id).unwrap();
@ -180,13 +182,13 @@ fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password()
let mut h = harness(s.clone());
h.get_by_label_contains("password-protected");
h.get_by_label("Recover").click();
h.run_steps(3);
support::wait_until(&mut h, "recovery password prompt", |app| app.password_prompt.is_some());
let field = h.get_by_role(Role::PasswordInput);
field.focus();
field.type_text("pw");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(4);
support::wait_until(&mut h, "unlocked recovery", |app| app.views.len() == 1);
let app = h.state();
assert_eq!(app.views.len(), 1);
let d = app.session.get(app.views[0].id).unwrap();
@ -205,8 +207,15 @@ fn a_cancelled_recovery_password_does_not_attach_to_the_next_unlocked_document()
assert_eq!(s.list().len(), 1);
let mut h = harness(s.clone());
h.get_by_label("Recover").click();
h.run_steps(3);
support::wait_until(&mut h, "recovery password prompt", |app| app.password_prompt.is_some());
assert!(h.state().password_prompt.is_some(), "the snapshot asks for its password");
// The new modal registers its layer before its controls become interactive.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while h.query_by_label("Cancel").is_none_or(|node| node.accesskit_node().is_disabled()) {
h.step();
assert!(std::time::Instant::now() < deadline, "the password Cancel button did not become enabled");
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.state().password_prompt.is_none());
@ -215,7 +224,7 @@ fn a_cancelled_recovery_password_does_not_attach_to_the_next_unlocked_document()
let app = h.state_mut();
app.open_bytes("B.pdf", Some("/docs/B.pdf".into()), encrypted(fixture(1), "pw-b")).unwrap();
app.submit_password(Some("pw-b".into()));
h.run_steps(3);
support::wait_until(&mut h, "unlocked B", |app| app.views.len() == 1);
let app = h.state();
assert_eq!(app.views.len(), 1);
let b = app.session.get(app.views[0].id).unwrap();
@ -291,7 +300,7 @@ fn missing_corrupt_or_mismatched_metadata_recovers_the_snapshot_without_an_origi
}
let mut h = harness(s.clone());
h.get_by_label("Recover").click();
h.run_steps(4);
support::wait_until(&mut h, "recovery without valid metadata", |app| app.views.len() == 1);
let app = h.state();
assert_eq!(app.views.len(), 1, "{case}");
let doc = app.session.get(app.views[0].id).unwrap();
@ -316,14 +325,14 @@ fn an_encrypted_snapshot_without_metadata_still_requires_its_password() {
std::fs::remove_file(s.dir().join(format!("{}.json", meta.key))).unwrap();
let mut h = harness(s.clone());
h.get_by_label("Recover").click();
h.run_steps(3);
support::wait_until(&mut h, "orphan recovery password prompt", |app| app.password_prompt.is_some());
assert!(h.state().views.is_empty());
let field = h.get_by_role(Role::PasswordInput);
field.focus();
field.type_text("pw");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(4);
support::wait_until(&mut h, "unlocked orphan recovery", |app| app.views.len() == 1);
let app = h.state();
assert_eq!(app.views.len(), 1);
let doc = app.session.get(app.views[0].id).unwrap();

View File

@ -0,0 +1,22 @@
//! State-based waits for native preparation, which completes between UI frames.
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
h.step();
if ready(h.state()) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}",
h.state().views.len(),
h.state().active,
h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)),
h.state().toast
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}

View File

@ -1,5 +1,7 @@
//! Headless UI tests (egui_kittest + AccessKit). They drive the real app shell without a window.
mod support;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_ui_egui::PdfCraftApp;
@ -681,9 +683,13 @@ fn dropping_a_pdf_on_the_window_opens_it() {
assert!(h.state().views.is_empty());
let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "dropped.pdf".into(), bytes: FIXTURE.to_vec() });
h.input_mut().dropped_files.push(file);
h.run_steps(3);
support::wait_until(&mut h, "dropped document", |app| app.views.len() == 1 && app.password_prompt.is_none());
assert_eq!(h.state().views.len(), 1, "the dropped PDF opens in a tab");
h.get_by_label_contains("dropped.pdf");
let (_, id) = h.state().active_ids().unwrap();
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.name, "dropped.pdf");
assert_eq!(doc.bytes.as_slice(), FIXTURE);
}
#[test]
@ -1180,3 +1186,74 @@ fn combine_tab_uses_tab_interaction_without_changing_close_or_document_state() {
assert!(h.query_by_role_and_label(Role::Tab, "Combine files").is_none());
assert_eq!(h.state().views.iter().map(|v| v.id).collect::<Vec<_>>(), document_ids);
}
/// Presses the named tab, drags it to `to` in a few steps and, unless `release` is false, lets go.
fn drag_named_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str, to: egui::Pos2, release: bool) {
let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect().center();
h.event(egui::Event::PointerMoved(from));
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
for k in 1..=6 {
h.event(egui::Event::PointerMoved(from.lerp(to, k as f32 / 6.0)));
h.run_steps(1);
}
if release {
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
}
}
#[test]
fn dragging_a_tab_moves_it_and_keeps_the_same_document_active() {
use egui::accesskit::Role;
let mut h = three_tab_harness();
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
let tab = |h: &Harness<'static, PdfCraftApp>, name: &str| h.get_by_role_and_label(Role::Tab, name).rect();
// first.pdf past the end; last.pdf, the active one, keeps the focus at its new place.
let to = tab(&h, "last.pdf").right_center() + egui::vec2(20.0, 0.0);
drag_named_tab(&mut h, "first.pdf", to, true);
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), [ids[1], ids[2], ids[0]]);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2]));
assert!(tab(&h, "first.pdf").left() > tab(&h, "last.pdf").right(), "the strip draws the new order");
// last.pdf, now in the middle, to the front.
let to = tab(&h, "middle.pdf").left_center() - egui::vec2(10.0, 0.0);
drag_named_tab(&mut h, "last.pdf", to, true);
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), [ids[2], ids[1], ids[0]]);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2]));
assert_eq!(h.get_by_role_and_label(Role::Tab, "last.pdf").accesskit_node().is_selected(), Some(true));
// Moving tabs touches no document.
assert_eq!(h.state().session.docs().len(), 3);
for doc in h.state().session.docs() {
assert_eq!(doc.bytes.as_slice(), FIXTURE);
assert!(!doc.dirty);
}
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
let to = tab(&h, "first.pdf").left_center() - egui::vec2(10.0, 0.0);
drag_named_tab(&mut h, "last.pdf", to, false);
h.render().unwrap().save(format!("{dir}/dragging-tab.png")).unwrap();
}
}
#[test]
fn a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it() {
let mut h = three_tab_harness();
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
let middle = h.get_by_role_and_label(egui::accesskit::Role::Tab, "middle.pdf").rect();
drag_named_tab(&mut h, "middle.pdf", middle.center() + egui::vec2(30.0, 0.0), true);
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), ids);
h.get_by_label("first.pdf").click();
h.run_steps(3);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[0]));
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), ids);
}
#[test]
fn the_combine_tab_does_not_move_and_documents_stay_in_order() {
let mut h = three_tab_harness();
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
h.state_mut().execute("page.combine");
h.run_steps(3);
let first = h.get_by_role_and_label(egui::accesskit::Role::Tab, "first.pdf").rect();
drag_named_tab(&mut h, "Combine files", first.left_center() - egui::vec2(10.0, 0.0), true);
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), ids);
}

View File

@ -1206,6 +1206,9 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() {
});
h.run_steps(delay);
let before = h.state().views[view].page_screen_rect(0).unwrap().top();
// One Line notch follows this platform's configured speed. A Point tail can
// still add travel in the one-frame case, so require at least the whole notch.
let expected = h.ctx.options(|o| o.input_options.line_scroll_speed);
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
@ -1215,6 +1218,10 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() {
h.run_steps(40);
let after = h.state().views[view].page_screen_rect(0).unwrap().top();
eprintln!("Point-to-Line delay={delay}: point={}, line={}", start - before, before - after);
assert!(before - after > 50.0, "Line input must resume after Point: {before} -> {after}");
let travelled = before - after;
assert!(travelled >= expected - 1.0, "Line input must resume after Point at the configured speed: {travelled} vs {expected}");
if delay >= 16 {
assert!((travelled - expected).abs() < 1.0, "settled Line travel must match its configured speed: {travelled} vs {expected}");
}
}
}

View File

@ -32,8 +32,9 @@ allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"
# Shared UI components; Cargo.toml pins the immutable revision.
allow-git = ["https://github.com/storytold/craft-libs"]
# Shared UI components and spooler; Cargo.toml pins the immutable revision. craft-print pins
# storytold's winprint fork (Windows printing, #1107) to an immutable revision of its own.
allow-git = ["https://github.com/storytold/craft-libs", "https://github.com/storytold/winprint.rs"]
[advisories]
yanked = "warn"
@ -43,6 +44,10 @@ ignore = [
# private-key operations use aws-lc-rs on native targets and are refused in the browser
# (ADR-0009). Revisit when a fixed `rsa` ships.
{ id = "RUSTSEC-2023-0071", reason = "rsa is used for verification only (ADR-0009)" },
# proc-macro-error is unmaintained (no vulnerability). It reaches the tree as a proc macro of
# winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows
# and is not in the binary.
{ id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" },
]
# egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md
@ -50,3 +55,11 @@ ignore = [
[[licenses.exceptions]]
crate = "epaint_default_fonts"
allow = ["OFL-1.1", "Ubuntu-font-1.0"]
# winprint (craft-print's Windows spooler, #1107) lists reqwest as a build-dependency for its
# PDFium download, which only runs with its `pdfium` feature; craft-print turns that off. The CA
# bundle reqwest brings is CDLA-Permissive-2.0 data and is compiled into winprint's build script
# only, never into PdfCraft. Remove when winprint makes those build-dependencies optional.
[[licenses.exceptions]]
crate = "webpki-root-certs"
allow = ["CDLA-Permissive-2.0"]

View File

@ -1,5 +1,6 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@ -90,7 +91,7 @@ total matches the parity estimate.
| 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. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<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). |

View File

@ -22,7 +22,7 @@ milestone = "M1"
status = "shipped"
commands = ["file.open"]
tools = ["doc_open"]
evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system"]
evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits"]
notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
[[feature]]
@ -166,7 +166,7 @@ milestone = "M1"
status = "shipped"
commands = ["file.open"]
tools = ["doc_open"]
evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking"]
evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt"]
notes = ""
[[feature]]
@ -1188,10 +1188,10 @@ area = "B"
tier = "P1"
milestone = "M3"
status = "shipped"
commands = []
commands = ["view.next_tab", "view.previous_tab"]
tools = ["doc_list"]
evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch"]
notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll"
evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch", "crates/ui-egui/tests/ui.rs::dragging_a_tab_moves_it_and_keeps_the_same_document_active", "crates/ui-egui/tests/ui.rs::a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it", "crates/ui-egui/tests/ui.rs::the_combine_tab_does_not_move_and_documents_stay_in_order", "crates/ui-egui/tests/commands.rs::ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around"]
notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll. Document tabs are dragged to reorder them (#1537): a line marks where the tab will land, the strip scrolls at its edges when it overflows, and the same document stays active; the Combine files tab stays after the documents. Dragging a tab out into its own window is view.multiple-windows. Ctrl+Tab and Ctrl+Shift+Tab (⌃Tab and ⌃⇧Tab on macOS; View ▸ Next tab / Previous tab) step through the tabs in their order on screen, Combine files included, and wrap around at either end (#1542)."
[[feature]]
id = "view.multiple-windows"
@ -3436,8 +3436,8 @@ milestone = "M3"
status = "shipped"
commands = ["file.properties"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding"]
notes = ""
evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"]
notes = "name, type, embedding, subset and encoding; doc_info also reports the face standing in for a program the document does not embed and why it was chosen (Acrobat's Actual Font), including the case where no licensed face exists so nothing is drawn. Still missing: the Fonts tab in the interface does not show the substituted face yet, only the data layer does"
[[feature]]
id = "organize.initial-view"
@ -7925,7 +7925,7 @@ 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::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", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job"]
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. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't)"
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; 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. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't)"
[[feature]]
id = "print.copies-collate"

View File

@ -47,7 +47,7 @@ pub const ADOBE_DATA_ALLOWED: &[(&str, &str)] = &[("hayro-cmap", "assets/cmaps.b
/// technical data we hold ourselves rather than through a dependency. Each still needs an
/// `[[asset]]` entry with `adobe_data = true`, so its SHA-256 is checked and any change to the
/// table has to be re-attested deliberately.
pub const ADOBE_DATA_FILES: &[&str] = &["crates/fonts/src/std14.rs"];
pub const ADOBE_DATA_FILES: &[&str] = &["crates/fonts/src/std14_metrics.rs"];
/// File extensions that count as assets wherever they appear in the repository.
const ASSET_EXTENSIONS: &[&str] = &[
@ -580,7 +580,7 @@ mod tests {
fn first_party_adobe_data_only_on_the_closed_list() {
let l = lock(&[]);
// The generated standard-14 table is on the list, as `kind = "data"`.
let mut ok = asset("crates/fonts/src/std14.rs", "Adobe (Core14 AFM metrics); tables by the PdfCraft contributors", "data", "MIT");
let mut ok = asset("crates/fonts/src/std14_metrics.rs", "Adobe (Core14 AFM metrics); tables by the PdfCraft contributors", "data", "MIT");
ok.adobe_data = true;
let m = Manifest { asset: vec![ok], ..Default::default() };
assert!(check(&root(), &m, &[], &l).is_empty(), "{:?}", check(&root(), &m, &[], &l));
@ -592,7 +592,7 @@ mod tests {
assert!(check(&root(), &m, &[], &l).iter().any(|p| p.contains("closed list")));
// Even on the list, it may only be data — never a typeface.
let mut wrong_kind = asset("crates/fonts/src/std14.rs", "Adobe", "font", "MIT");
let mut wrong_kind = asset("crates/fonts/src/std14_metrics.rs", "Adobe", "font", "MIT");
wrong_kind.adobe_data = true;
let m = Manifest { asset: vec![wrong_kind], ..Default::default() };
assert!(!check(&root(), &m, &[], &l).is_empty());