diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8249dcf0c..388b44f03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,13 +48,21 @@ jobs: echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV" echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV" - - name: Install Linux system dependencies (egui/winit/rfd) + - name: Install Linux system dependencies (egui/winit/rfd) and the oracles if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev + libgl1-mesa-dev libgtk-3-dev \ + qpdf poppler-utils + + # The independent oracles (crates/oracle) are only worth having when they run. With this + # set, a missing one fails the job instead of printing a skip notice that reads as a pass. + # Only the tools the job actually uses are installed above. + - name: Require the oracles (Linux) + if: runner.os == 'Linux' + run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: diff --git a/AGENTS.md b/AGENTS.md index 2ac90cfdf..0665ad1d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e0d0ede28..3fc48a255 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (273) +## In this repository (274) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -226,6 +226,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_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. | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 58d05857b..e33b0f72b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d0a69a35403e70b94c3adfef88b41ec719f4f6ab700d351c8f82a78c53b63ef6" +sha256 = "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 = "8a1bd9d28658db4d2106840837da608203efbcfdba1f350b58549518dea789ad" +sha256 = "2002d05324adc2174fe22416455666fb2083a64f1db2f0be42de2407adc23074" [[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 = "crates/ui-egui/src/i18n/id.tsv" @@ -239,7 +239,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "364cfd8a35eae330bb233fe5a2dc3f7cdfdfdbee13570fa2cbc3664d12a42621" +sha256 = "136e7f8ce1d30076be01fa6d67a659eba7837ed69dc7f8e8888c2cfd89c2cbdf" [[asset]] path = "crates/ui-egui/src/i18n/vi.tsv" @@ -250,7 +250,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c206972f2ae3430bf1d1745c272e8a25ecabf8c484c1ad31e69ceece8adc933" +sha256 = "1d98829ce0da091cdbc67d6a1b153a7a951abc6dffbad848ada3d0453a2d097c" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -261,7 +261,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8a7c2fb9c4ffe3b56cef928ee07269e93ce653fa89b7cd2d439231046ac05619" +sha256 = "f64f01d2514b017122b7394dcf31e90b8ec66e4ed36369ab476c1807e46c4593" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" @@ -272,7 +272,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c59ead567704ee3840c7da88fa06edf3b7315c6609eb2038384001c7ad0c1054" +sha256 = "85d69348918b89340ead8810bfa2dccd22d2ddec07e83feaa0469c437d77116d" [[asset]] path = "assets/icons/accessibility.svg" @@ -2408,6 +2408,18 @@ kind = "data" usage = "Default CMYK colour conversion (vendored hayro-interpret)" sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0" +[[asset]] +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. 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 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" + [[bundled]] crate = "epaint_default_fonts" version = "0.36.2" @@ -3383,4 +3395,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" diff --git a/Cargo.lock b/Cargo.lock index df58c57d2..93a8545ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3875,7 +3875,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -3897,7 +3897,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3909,7 +3909,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3920,7 +3920,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3931,7 +3931,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.7.1" +version = "0.8.0" dependencies = [ "base64 0.22.1", "flate2", @@ -3952,7 +3952,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -3967,22 +3967,24 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.7.1" +version = "0.8.0" [[package]] name = "pdfcraft-content" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", + "pdfcraft-oracle", + "pdfcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -3990,7 +3992,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.7.1" +version = "0.8.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4006,7 +4008,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.7.1" +version = "0.8.0" dependencies = [ "aes", "md-5", @@ -4018,7 +4020,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4033,7 +4035,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "chrono", "icu_properties", @@ -4070,14 +4072,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flate2", "proptest", @@ -4087,7 +4089,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.7.1" +version = "0.8.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4097,7 +4099,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4109,37 +4111,38 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.7.1" +version = "0.8.0" [[package]] name = "pdfcraft-js" -version = "0.7.1" +version = "0.8.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-testkit", "serde", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-model" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.7.1" +version = "0.8.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4149,7 +4152,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "pdfcraft-annot", @@ -4160,19 +4163,24 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-oracle" +version = "0.8.0" + [[package]] name = "pdfcraft-organize" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-oracle", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-platform" -version = "0.7.1" +version = "0.8.0" dependencies = [ "async-channel", "futures-lite", @@ -4181,7 +4189,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-cos", "pdfcraft-model", @@ -4189,7 +4197,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4200,7 +4208,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4209,12 +4217,13 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-testkit", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-render" -version = "0.7.1" +version = "0.8.0" dependencies = [ "bytemuck", "flate2", @@ -4233,7 +4242,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.7.1" +version = "0.8.0" dependencies = [ "aes", "aws-lc-rs", @@ -4265,9 +4274,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-testkit" +version = "0.8.0" + [[package]] name = "pdfcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4309,7 +4322,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -4321,7 +4334,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.7.1" +version = "0.8.0" dependencies = [ "base64 0.22.1", "image", @@ -4335,7 +4348,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -7946,6 +7959,8 @@ version = "0.1.0" dependencies = [ "anyhow", "lopdf", + "pdfcraft-cos", + "pdfcraft-oracle", "png 0.18.1", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e32b54444..84b2ed1f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -17,37 +17,39 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", # Shared system spooler; document composition remains in pdfcraft-print. 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" } -pdfcraft-cos = { path = "crates/cos", version = "0.7.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.7.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.7.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.7.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.7.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.7.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.7.0" } -pdfcraft-model = { path = "crates/model", version = "0.7.0" } -pdfcraft-content = { path = "crates/content", version = "0.7.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.7.0" } -pdfcraft-print = { path = "crates/print", version = "0.7.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.7.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.7.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.7.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.7.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.7.0" } -pdfcraft-js = { path = "crates/js", version = "0.7.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.7.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.7.0" } -pdfcraft-export = { path = "crates/export", version = "0.7.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.7.0" } -pdfcraft-create = { path = "crates/create", version = "0.7.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.7.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.7.0" } -pdfcraft-render = { path = "crates/render", version = "0.7.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.7.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.7.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.7.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.7.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.8.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.8.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.8.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.8.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.8.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.8.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.8.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" } +pdfcraft-model = { path = "crates/model", version = "0.8.0" } +pdfcraft-content = { path = "crates/content", version = "0.8.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.8.0" } +pdfcraft-print = { path = "crates/print", version = "0.8.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } +pdfcraft-js = { path = "crates/js", version = "0.8.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" } +pdfcraft-export = { path = "crates/export", version = "0.8.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.8.0" } +pdfcraft-create = { path = "crates/create", version = "0.8.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.8.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" } +pdfcraft-render = { path = "crates/render", version = "0.8.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.8.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.8.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/README.md b/README.md index b20179fff..86f7d7643 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Open **Organize pages** to see every page at once: **Saves you can trust:** - *Incremental:* the original bytes stay byte-for-byte intact. - *Atomic:* the file is written to a temporary copy, then swapped in. -- *Verified:* independently checked with qpdf. +- *Verified:* independently checked with qpdf, on every CI run. When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view. @@ -365,7 +365,8 @@ On top of those, two corpus sweeps run over real-world files: - **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes. - **Open, edit and save round trips:** 958 succeed. -The output is verified with independent tools: hayro, qpdf and poppler. +The output is verified against independent implementations: hayro and qpdf on every CI run, and +poppler over the corpus on demand (`cargo xtask text-oracle`). PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md). diff --git a/crates/automation/README.md b/crates/automation/README.md index f2e4ec928..d702b7ed4 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -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 (` is outside the allowed directory `), 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 diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d7eb8a2ba..4a34909de 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -858,6 +858,10 @@ impl Automation { } fn doc_save(&mut self, a: &Args) -> Result { + self.doc_save_with(a, write_atomic) + } + + fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { let doc = self.doc(a)?; let id = doc.id; let target = match a.opt_str("path")? { @@ -875,7 +879,8 @@ 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)?; + self.session.check_save(id, &bytes).map_err(failed)?; + 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 +2210,13 @@ fn info(d: &Document) -> Value { })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // `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::>(), "warnings": i.warnings, "repairs": d.repair_log(), @@ -2301,13 +2311,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) -> Result<()> { + write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) +} + +fn write_atomic_synced( + path: &Path, + bytes: &[u8], + suffixes: impl IntoIterator, + 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 +2343,9 @@ fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator Vec { let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0ff3930b4..e788d9610 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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" })); @@ -517,6 +524,44 @@ fn an_in_place_save_reports_a_required_rewrite_as_not_incremental() { } } +/// Issue #1698: a save whose bytes don't reopen is refused before it touches the file. The +/// catalog's `/Pages` is not a page tree, so the reader finds the page by scanning, while a +/// full rewrite keeps only what the catalog reaches and drops it. +#[test] +fn a_save_that_does_not_reopen_leaves_the_file_as_it_was() { + let dir = workdir("save-verify-before-write"); + let objs: [&[u8]; 4] = [ + b"<< /Type /Catalog /Pages 3 0 R >>", + b"<< /Type /Page /MediaBox [0 0 200 300] /Contents 4 0 R >>", + b"[ 0 ]", + b"<< /Length 18 >>\nstream\n0 0 m 100 100 l S\nendstream", + ]; + let mut original = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(original.len()); + original.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + original.extend_from_slice(o); + original.extend_from_slice(b"\nendobj\n"); + } + let xref = original.len(); + original.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for o in offsets { + original.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + original.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + std::fs::write(dir.join("damaged.pdf"), &original).unwrap(); + + let mut a = auto(&dir); + let opened = ok(&mut a, "doc_open", json!({ "path": "damaged.pdf" })); + assert_eq!(opened["pages"], 1, "{opened}"); + let doc = opened["doc"].as_u64().unwrap(); + let e = err(&mut a, "doc_save", json!({ "doc": doc, "full": true })); + assert!(e.to_string().contains("could not be reopened"), "{e}"); + let on_disk = std::fs::read(dir.join("damaged.pdf")).unwrap(); + assert!(on_disk == original, "the file was replaced by {} bytes that don't reopen", on_disk.len()); +} + /// organize.field-name-conflicts: combining files whose forms share a field name keeps the /// fields separate by renaming, says so in the result, and can be told to merge instead; any /// other policy is an argument error. diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/batch.rs index 20a335917..b2d342894 100644 --- a/crates/automation/tests/batch.rs +++ b/crates/automation/tests/batch.rs @@ -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 { 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)] @@ -132,13 +135,14 @@ fn non_utf8_discovered_names_are_reported_without_panicking() { 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"); - std::fs::write(&valid, pdf()).unwrap(); - assert_eq!(std::fs::read(&valid).unwrap(), 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(), pdf()); + 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" ); @@ -189,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) @@ -206,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"); } diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index f02f63c52..9ef9126ca 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-filters = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-oracle = { workspace = true } +pdfcraft-testkit = { workspace = true } serde_json = { workspace = true } proptest = { workspace = true } hayro-syntax = { workspace = true } diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index 6868a6dc2..cf519df67 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -202,24 +202,23 @@ fn unencrypted_metadata_is_left_readable() { let _ = ObjRef::new(1, 0); } -/// Second independent oracle when qpdf is installed: it must accept both passwords, identify -/// the owner password, and find no structural errors. +/// The second independent oracle: qpdf must accept both passwords, identify the owner password, +/// and find no structural errors. For R2-R4 this is the *only* check of owner-password +/// authentication (Algorithm 7) that does not go through our own code, so it is the one place a +/// silent skip would cost the most -- see `pdfcraft-oracle`, which makes absence loud. #[test] fn qpdf_agrees() { - if std::process::Command::new("qpdf").arg("--version").output().is_err() { - eprintln!("qpdf not installed; skipping"); - return; - } + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { - let out = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); + let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); assert!(text.contains(&format!("Supplied password is {expect}")), "{alg:?}/{pw}: {text}"); - let check = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap(); assert!(check.status.success(), "{alg:?}/{pw}: {}", String::from_utf8_lossy(&check.stdout)); } } diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index b7514cc05..a7f071e8e 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -4,34 +4,23 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; /// Four pages, a shared font, document info with a non-ASCII title. fn fixture() -> Vec { - let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; let kids: Vec = (0..4).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); - objs.push(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))); - objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + let mut pdf = Pdf::new() + .version("1.4") + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); for i in 0..4 { - objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); - let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); - objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + pdf = pdf + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)) + .stream("", format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1)); } - objs.push("<< /Title (Caf\\351 \\(draft\\)) >>".into()); - let mut out = b"%PDF-1.4\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice( - format!("trailer\n<< /Size {} /Root 1 0 R /Info {} 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len()).as_bytes(), - ); - out + let info = pdf.next_obj(); + pdf.obj("<< /Title (Caf\\351 \\(draft\\)) >>").trailer_entry(&format!("/Info {info} 0 R")).build() } fn title(doc: &Document) -> Vec { @@ -159,18 +148,14 @@ fn classic_table() -> Vec { bytes } -fn qpdf_check(bytes: &[u8]) -> Option { - let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else { - return None; - }; - assert!(version.status.success()); +fn qpdf_check(qpdf: &pdfcraft_oracle::Oracle, bytes: &[u8]) -> std::process::Output { static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id())); std::fs::write(&path, bytes).unwrap(); - let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg("--check").arg(&path).output().unwrap(); let _ = std::fs::remove_file(&path); - Some(check) + check } fn page(doc: &Document) -> Dict { @@ -181,10 +166,8 @@ fn page(doc: &Document) -> Dict { } fn assert_qpdf_clean(bytes: &[u8]) { - let Some(check) = qpdf_check(bytes) else { - eprintln!("qpdf not installed: skipped"); - return; - }; + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); + let check = qpdf_check(&qpdf, bytes); let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr)); assert!(check.status.success() && !report.contains("WARNING"), "{report}"); } @@ -247,16 +230,12 @@ fn incremental_save_keeps_a_trailer_only_edit() { #[test] fn qpdf_accepts_object_stream_output() { - let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else { - eprintln!("qpdf not installed: skipped"); - return; - }; - assert!(out.status.success()); + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); let doc = Document::open(Arc::new(fixture())).unwrap(); let packed = write_full(&doc, &SaveOptions::default()).unwrap(); let path = std::env::temp_dir().join(format!("pdfcraft-objstm-{}.pdf", std::process::id())); std::fs::write(&path, &packed).unwrap(); - let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg("--check").arg(&path).output().unwrap(); let _ = std::fs::remove_file(&path); let report = String::from_utf8_lossy(&check.stdout); assert!(check.status.success() && report.contains("No syntax or stream encoding errors"), "{report}"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c3294dc1e..cf4038645 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15c0551b1..fbe62b2c2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3741,6 +3741,16 @@ impl Session { guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file /// so the next save appends only newer edits. Undo history is kept. pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 76fd89dd8..e7e3ef117 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -4,9 +4,11 @@ Layer L2. Font helpers for the appearance streams PdfCraft generates (comments, Today it holds what generated appearances need without a font program: -- `helvetica_width`: an approximation of Helvetica's proportions by character class. No metrics - file or font program from any vendor is bundled; widths are PdfCraft's own estimates, good - enough for line breaking and alignment, not for typesetting. +- `helvetica_width`: the **exact** standard-14 advance widths, via `Std14::Helvetica`. It was an + estimate by character class until the generated tables landed; those tables are Adobe-authored + non-visual technical data, the item `AGENTS.md` §1.1 permits, carried in `std14_metrics.rs` with + `adobe_data = true` in `ATTRIBUTION.toml`. **No font program or typeface from any vendor is + bundled** — widths and encodings only. Reach for `Std14` directly for any other face. - `wrap`: greedy line breaking with that measure (paragraphs on newlines, long words split). - `win_ansi`: Unicode → WinAnsiEncoding bytes (`?` for characters it can't represent), and `literal` to write bytes as a PDF literal string. Nothing writes that `?` silently. Callers diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs new file mode 100644 index 000000000..64069a3de --- /dev/null +++ b/crates/fonts/src/fallback.rs @@ -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 { + 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:?}"); + } + } + } + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 140e5619c..81a41a1be 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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_devanagari_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, diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs index 0aca38a12..0556b112c 100644 --- a/crates/fonts/src/std14.rs +++ b/crates/fonts/src/std14.rs @@ -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::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::*; diff --git a/crates/fonts/src/std14_metrics.rs b/crates/fonts/src/std14_metrics.rs new file mode 100644 index 000000000..05ba41c71 --- /dev/null +++ b/crates/fonts/src/std14_metrics.rs @@ -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, + ], +]; diff --git a/crates/measure/Cargo.toml b/crates/measure/Cargo.toml index eb2de9422..66f071c92 100644 --- a/crates/measure/Cargo.toml +++ b/crates/measure/Cargo.toml @@ -15,3 +15,6 @@ pdfcraft-content = { workspace = true } pdfcraft-annot = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 20a730bed..6af32632f 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -1,30 +1,18 @@ use super::*; use pdfcraft_cos::{SaveOptions, write_full}; +use pdfcraft_testkit::Pdf; use std::sync::Arc; fn fixture(content: &str, attrs: &str, extra: &[&str]) -> Document { fixture_with("4 0 R", content, attrs, extra) } fn fixture_with(contents: &str, content: &str, attrs: &str, extra: &[&str]) -> Document { - let mut objects = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), - format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>"), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - objects.extend(extra.iter().map(|s| s.to_string())); - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objects.iter().enumerate() { - offsets.push(bytes.len()); - bytes.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = bytes.len(); - bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); - for o in offsets { - bytes.extend(format!("{o:010} 00000 n \n").as_bytes()); - } - bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); - Document::open(Arc::new(bytes)).unwrap() + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>")) + .stream("", content) + .objs(extra); + Document::open(Arc::new(pdf.build())).unwrap() } fn close(a: f64, b: f64) { assert!((a - b).abs() < 1e-8, "{a} != {b}"); diff --git a/crates/oracle/Cargo.toml b/crates/oracle/Cargo.toml new file mode 100644 index 000000000..45b27f5d9 --- /dev/null +++ b/crates/oracle/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pdfcraft-oracle" +description = "External oracle processes PdfCraft checks its own output against, and the rule that a missing one is loud" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/oracle/README.md b/crates/oracle/README.md new file mode 100644 index 000000000..7d2996b77 --- /dev/null +++ b/crates/oracle/README.md @@ -0,0 +1,101 @@ +# pdfcraft-oracle + +The external programs PdfCraft checks its own output against, and one rule: **a missing oracle is +either a declared skip or a failure, never a silent pass.** + +- **Layer:** test support (`Class::Testkit` in `xtask/src/layers.rs`). A dev-dependency only, never + compiled into the product, and so usable even from the standalone crates (`cos`, `geom`, + `filters`, `crypt`, `arlington`) whose own tests need it. +- No panicking path of its own. The `or_skip!` macro expands its `assert!` into the caller, which + is test code. + +## Why it exists + +Every test that wanted an oracle used to probe for it by hand and decide on its own what to do when +it was missing. The common choice — print a line and return — reports as a **pass**, so a suite +with no oracles installed looked exactly like one in which every oracle agreed. + +That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/encryption.rs::qpdf_agrees` +had never executed on a runner, while `README.md` said saved output was "independently checked with +qpdf". For R2–R4 that test is the *only* check of owner-password authentication (Algorithm 7) that +does not go through our own code. + +## API + +```rust +// In a test: bind the oracle, or return after saying why it was skipped. +let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); +let out = qpdf.command().arg("--check").arg(&path).output()?; + +// Elsewhere (xtask), handle the absence yourself. +match pdfcraft_oracle::pdftotext() { + Ok(tool) => { /* tool.command() */ } + Err(absent) => bail!("{absent}"), +} +``` + +| Probe | Tool | Used for | +|---|---|---| +| `qpdf()` | qpdf | `--check` structural validation; the independent owner-password oracle | +| `pdftoppm()` | Poppler | page pixels, for comparing against our own raster output | +| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 against it) | + +Each returns `Result`. `Oracle::command()` hands back a fresh `Command`; +`Absent` renders as an actionable sentence naming the install command. + +## The gate + +`PDFCRAFT_ORACLES_REQUIRED` (any value, including `0` — the same convention as +`CRAFT_FONTS_REQUIRED`) turns absence from a skip into a failure. CI sets it on the runners that +install the tools, so a skip notice there becomes a red build. + +``` +$ cargo test -p pdfcraft-cos --test encryption qpdf_agrees +test result: ok. 1 passed # qpdf absent: skipped, with a notice on stderr + +$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test encryption qpdf_agrees +oracle `qpdf` is required but is not on PATH (program not found): PDFCRAFT_ORACLES_REQUIRED is +set, so this is a failure rather than a skip (apt install qpdf / brew install qpdf) +test result: FAILED. 1 failed +``` + +`or_skip!` expands to a `return`, so it belongs in a test returning `()`; a test returning `Result` +should match on the probe directly. + +## Comparing pixels (`pixels`) + +Rendering needs more than "did the tool run". A raw pixel-for-pixel difference is useless here: +two correct renderers disagree on every glyph edge, so a strict comparison calls a perfect page +badly wrong and tells you nothing about whether a shading is missing. + +`pixels::compare` counts a pixel as differing only when it differs from **every** pixel in the +other image's 3x3 neighbourhood by more than a tolerance. A one-pixel shift or a differently +weighted edge finds a near match next door and is forgiven; a glyph that is not there, or a region +painted the wrong colour, has no near match and is counted. The test runs in both directions and +reports the worse result, so neither argument order flatters the blurrier renderer. + +`decode_pgm` reads what `pdftoppm -gray` writes; `decode_pam_gray` reads what +`pdfcraft-cli render --out x.pam` writes, compositing premultiplied alpha over white first so a +transparent margin is not scored as black. + +`cargo xtask render-oracle` drives it over the corpus. It reports; it does not gate. + +A high score is a *disagreement*, not a verdict on us. The worst file of the first 250, +`bug1552113.pdf` at 84%, is Poppler painting a link's `/Border [0 0 112]` as a band over the text +the fixture exists to show; ours is the readable rendering. Look at both images before believing +the number. `--hide-annotations` separates annotation differences from page content, though the +default shows them: hiding is slightly worse overall, because it then scores Poppler for every +appearance we do draw. + +**Poppler is not a usable oracle for JBIG2.** The 96 `bitmap-*.pdf` corpus fixtures encode one +picture through every corner of the format, so a correct decoder renders them all identically. We +render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them. +`crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. + +## Still to do + +- Acrobat itself as the reference, rather than other open renderers. +- Oracles that are not external processes (hayro, `lopdf`) are ordinary dev-dependencies and are + not routed through this crate. +- Two renderers sometimes round the same page box to different pixel sizes, and those files are + counted as unmeasurable rather than compared. diff --git a/crates/oracle/src/lib.rs b/crates/oracle/src/lib.rs new file mode 100644 index 000000000..e9e7ae372 --- /dev/null +++ b/crates/oracle/src/lib.rs @@ -0,0 +1,248 @@ +//! External oracle processes: independent implementations PdfCraft checks its own output against. +//! +//! An oracle is only worth having when it runs. Before this crate every test that wanted one +//! probed for it by hand and decided on its own what to do when it was missing, and the common +//! choice — print a line and return — reports as a **pass**. A suite with no oracles installed +//! therefore looked exactly like one in which every oracle agreed, and on this repository's CI, +//! where none of them were installed, that is precisely what was happening: `qpdf` is the only +//! independent check of owner-password authentication for R2–R4, and it had never executed on a +//! runner while `README.md` said the output was "independently checked with qpdf". +//! +//! So the rule here is one line of policy: **a missing oracle is either a declared skip or a +//! failure, never a silent pass.** [`REQUIRED_VAR`] decides which. CI sets it, so absence breaks +//! the build; a contributor's laptop usually does not, so absence prints why and skips. +//! +//! ```no_run +//! # fn main() { +//! let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); +//! let out = qpdf.command().arg("--check").arg("f.pdf").output(); +//! # let _ = out; +//! # } +//! ``` +//! +//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency +//! only, never compiled into the product. + +pub mod pixels; + +use std::fmt; +use std::process::Command; + +/// Setting this environment variable to any value turns a missing oracle from a skip into a +/// failure. CI sets it on the runners that install the tools. +/// +/// Any value counts, including `0` — the same convention as `CRAFT_FONTS_REQUIRED`, which this +/// deliberately mirrors. The surprising half of that convention errs toward *requiring* an oracle, +/// which is the safe direction for a gate whose whole purpose is to stop absence passing quietly. +pub const REQUIRED_VAR: &str = "PDFCRAFT_ORACLES_REQUIRED"; + +/// An external program found on `PATH`, ready to run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Oracle { + program: &'static str, + version: String, +} + +impl Oracle { + /// A fresh [`Command`] for the program. Callers add their own arguments. + pub fn command(&self) -> Command { + Command::new(self.program) + } + + /// The program name, as it is looked up on `PATH`. + pub fn program(&self) -> &'static str { + self.program + } + + /// The first line the program printed when asked for its version, for test output. Empty when + /// it printed nothing recognisable; presence of the binary is what decides availability, not + /// whether its version could be parsed. + pub fn version(&self) -> &str { + &self.version + } +} + +/// An oracle that could not be used, and whether that is fatal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Absent { + program: &'static str, + cause: String, + required: bool, +} + +impl Absent { + /// Whether this absence must fail the run rather than skip it, i.e. whether [`REQUIRED_VAR`] + /// was set when the probe ran. + pub fn required(&self) -> bool { + self.required + } + + /// The program that was looked for. + pub fn program(&self) -> &'static str { + self.program + } + + /// How to install it, so a skip notice tells the reader what to do about it. + fn install_hint(program: &str) -> &'static str { + match program { + "qpdf" => "apt install qpdf / brew install qpdf", + "pdftoppm" | "pdftotext" => "apt install poppler-utils / brew install poppler", + _ => "install it and put it on PATH", + } + } +} + +impl fmt::Display for Absent { + /// Reads as an instruction either way: required absences are failures and name the gate that + /// made them one, optional absences name the install command. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.required { + write!( + f, + "oracle `{}` is required but {}: {} is set, so this is a failure rather than a skip ({})", + self.program, + self.cause, + REQUIRED_VAR, + Self::install_hint(self.program), + ) + } else { + write!( + f, + "skipping: oracle `{}` {} ({}); set {} to make this a failure instead", + self.program, + self.cause, + Self::install_hint(self.program), + REQUIRED_VAR, + ) + } + } +} + +/// Look for `program` on `PATH`, asking it for its version to confirm it runs. +/// +/// Presence is decided by whether the program could be started at all, not by its exit status: a +/// tool that runs but reports its version oddly is still an oracle. +pub fn find(program: &'static str, version_arg: &str) -> Result { + let required = std::env::var_os(REQUIRED_VAR).is_some(); + match Command::new(program).arg(version_arg).output() { + Ok(out) => { + // Poppler's tools print their version to stderr, qpdf to stdout. + let text = if out.stdout.is_empty() { &out.stderr } else { &out.stdout }; + let version = String::from_utf8_lossy(text).lines().next().unwrap_or_default().trim().to_owned(); + Ok(Oracle { program, version }) + } + Err(e) => Err(Absent { program, cause: format!("is not on PATH ({e})"), required }), + } +} + +/// qpdf: structural validation (`--check`) and the independent owner-password oracle. +pub fn qpdf() -> Result { + find("qpdf", "--version") +} + +/// Poppler's `pdftoppm`: renders a page to pixels, for comparing against our own raster output. +pub fn pdftoppm() -> Result { + find("pdftoppm", "-v") +} + +/// Poppler's `pdftotext`: the text-extraction oracle (`cargo xtask text-oracle` scores word F1 +/// against it over the corpus). +pub fn pdftotext() -> Result { + find("pdftotext", "-v") +} + +/// Binds an oracle, or returns from the calling test after saying on stderr why it was skipped. +/// +/// When [`REQUIRED_VAR`] is set the absence fails the test instead. The panic is deliberate and is +/// how a test reports failure; it expands into the caller, which is test code, so the workspace's +/// never-panic rule is not weakened — this crate itself contains no panicking path. +/// +/// The macro expands to a `return`, so it belongs in a test function returning `()`. A test that +/// returns `Result` should match on the probe itself. +#[macro_export] +macro_rules! or_skip { + ($probe:expr) => { + match $probe { + ::core::result::Result::Ok(oracle) => oracle, + ::core::result::Result::Err(absent) => { + let absent: $crate::Absent = absent; + ::core::assert!(!$crate::Absent::required(&absent), "{absent}"); + ::std::eprintln!("{absent}"); + return; + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The one behaviour the crate exists for: absence is never silent, and which of the two loud + /// forms it takes is decided by the environment variable. + #[test] + fn a_missing_oracle_is_fatal_exactly_when_it_is_required() { + let optional = Absent { program: "qpdf", cause: "is not on PATH".into(), required: false }; + let required = Absent { required: true, ..optional.clone() }; + + assert!(!optional.required()); + assert!(required.required()); + + // Both say what is wrong and how to fix it; only the required one calls itself a failure. + let (opt, req) = (optional.to_string(), required.to_string()); + assert!(opt.contains("skipping"), "{opt}"); + assert!(opt.contains("apt install qpdf"), "{opt}"); + assert!(opt.contains(REQUIRED_VAR), "{opt}"); + assert!(req.contains("required"), "{req}"); + assert!(req.contains("failure"), "{req}"); + assert!(!req.contains("skipping"), "{req}"); + } + + /// A tool that does not exist is reported as absent rather than hanging or panicking, and the + /// reason names the program. + #[test] + fn a_program_that_does_not_exist_is_absent_with_a_reason() { + let Err(absent) = find("pdfcraft-no-such-oracle-binary", "--version") else { + panic!("a nonexistent program must not be reported as present"); + }; + assert_eq!(absent.program(), "pdfcraft-no-such-oracle-binary"); + let text = absent.to_string(); + assert!(text.contains("pdfcraft-no-such-oracle-binary"), "{text}"); + assert!(text.contains("not on PATH"), "{text}"); + } + + /// Hostile and odd program names must not panic, hang or be mistaken for a working oracle. + #[test] + fn hostile_program_names_are_absent_not_a_panic() { + for name in ["", " ", "\0bad", "../../etc/passwd", &"x".repeat(4096)] { + // `find` takes a 'static str; leak the generated ones, which is fine in a test. + let name: &'static str = Box::leak(name.to_owned().into_boxed_str()); + let found = find(name, "--version"); + if let Ok(o) = found { + // Nothing by these names should run; if one somehow does, it must still be sane. + assert_eq!(o.program(), name); + } + } + } + + /// The install hint is specific per tool, so a skip notice is actionable. + #[test] + fn every_known_oracle_has_an_install_hint() { + for p in ["qpdf", "pdftoppm", "pdftotext"] { + let hint = Absent::install_hint(p); + assert!(hint.contains("install"), "{p}: {hint}"); + assert_ne!(hint, Absent::install_hint("something-else"), "{p} needs its own hint"); + } + } + + /// Probing is read-only and repeatable: asking twice gives the same answer. + #[test] + fn probing_is_stable() { + let a = qpdf().map(|o| o.program()); + let b = qpdf().map(|o| o.program()); + assert_eq!(a.is_ok(), b.is_ok()); + if let (Ok(a), Ok(b)) = (a, b) { + assert_eq!(a, b); + } + } +} diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs new file mode 100644 index 000000000..7365e37ea --- /dev/null +++ b/crates/oracle/src/pixels.rs @@ -0,0 +1,428 @@ +//! Comparing a rendered page against an independent renderer's pixels. +//! +//! A raw pixel-for-pixel difference is useless for this. Two correct renderers disagree on every +//! glyph edge — antialiasing coverage, hinting, and where a subpixel-positioned stem lands — so a +//! strict comparison reports a page as badly wrong when it is perfect, and the number it produces +//! tells you nothing about whether a shading is missing or a glyph is the wrong one. +//! +//! [`compare`] uses the usual remedy: a pixel counts as differing only when it differs from +//! **every** pixel in the other image's 3×3 neighbourhood by more than `tolerance`. A one-pixel +//! shift in a stem, or a differently-weighted edge, finds a near match next door and is forgiven. +//! A glyph that is not there, a shading that was not drawn, or a region painted the wrong colour +//! has no near match anywhere nearby and is counted. +//! +//! The test is run in both directions and the worse result reported, because the one-directional +//! form is asymmetric in a way that flatters whichever image is blurrier: thin content missing +//! from A is forgiven when scanning A against B's neighbourhood if B's stroke happens to be wide. + +/// A single-channel (grayscale) image borrowed for comparison. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Gray<'a> { + width: usize, + height: usize, + pixels: &'a [u8], +} + +impl<'a> Gray<'a> { + /// Borrow `pixels` as a `width` × `height` image, or `None` when the buffer is not exactly + /// that size — a truncated decode must not be compared as if it were a short page. + pub fn new(width: usize, height: usize, pixels: &'a [u8]) -> Option { + (width.checked_mul(height)? == pixels.len()).then_some(Self { width, height, pixels }) + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + /// The pixel at `(x, y)`, or `None` outside the image. + pub fn get(&self, x: usize, y: usize) -> Option { + if x >= self.width || y >= self.height { + return None; + } + self.pixels.get(y.checked_mul(self.width)?.checked_add(x)?).copied() + } +} + +/// Why two images could not be compared. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SizeMismatch { + pub a: (usize, usize), + pub b: (usize, usize), +} + +impl std::fmt::Display for SizeMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "cannot compare a {}x{} image with a {}x{} one; render both at the same scale", self.a.0, self.a.1, self.b.0, self.b.1) + } +} + +/// How far apart two renderings are. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Diff { + /// Pixels with no near match in the other image's neighbourhood, in the worse direction. + pub differing: usize, + /// Pixels in the image. + pub total: usize, + /// The largest single-pixel difference found, before the neighbourhood is considered. A high + /// value with a low [`Diff::fraction`] is the signature of an edge-only disagreement. + pub max_delta: u8, +} + +impl Diff { + /// The share of pixels with no near match, in `0.0..=1.0`. Zero for an empty image: nothing + /// disagreed because there was nothing to disagree about. + pub fn fraction(&self) -> f64 { + if self.total == 0 { + return 0.0; + } + // total is non-zero, and differing <= total, so this is finite and in 0..=1. + self.differing as f64 / self.total as f64 + } + + /// `fraction` as a percentage, for reports. + pub fn percent(&self) -> f64 { + self.fraction() * 100.0 + } +} + +/// Compare two grayscale renderings of the same page at the same size. +/// +/// `tolerance` is the per-channel difference tolerated outright; 8 or so absorbs ordinary +/// antialiasing weight differences without hiding a wrong colour. +pub fn compare(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> Result { + if a.width != b.width || a.height != b.height { + return Err(SizeMismatch { a: (a.width, a.height), b: (b.width, b.height) }); + } + let max_delta = a.pixels.iter().zip(b.pixels).map(|(&p, &q)| p.abs_diff(q)).max().unwrap_or(0); + let forward = unmatched(a, b, tolerance); + let backward = unmatched(b, a, tolerance); + Ok(Diff { differing: forward.max(backward), total: a.pixels.len(), max_delta }) +} + +/// Pixels of `a` with no pixel within `tolerance` anywhere in `b`'s surrounding 3x3. +fn unmatched(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> usize { + let mut count = 0usize; + for y in 0..a.height { + for x in 0..a.width { + let Some(p) = a.get(x, y) else { continue }; + let mut matched = false; + // Saturating bounds keep the window inside the image at the edges; `get` rejects the + // rest, so no index is ever derived from an unchecked subtraction. + for ny in y.saturating_sub(1)..=y.saturating_add(1) { + for nx in x.saturating_sub(1)..=x.saturating_add(1) { + if b.get(nx, ny).is_some_and(|q| p.abs_diff(q) <= tolerance) { + matched = true; + break; + } + } + if matched { + break; + } + } + if !matched { + count = count.saturating_add(1); + } + } + } + count +} + +/// Decode a binary netpbm grayscale image (`P5`), which is what `pdftoppm -gray` writes. +/// +/// Returns the dimensions and the pixel bytes. Only the maxval 255 form is accepted: a 16-bit +/// image would otherwise be read as twice as many 8-bit pixels and compared against nonsense. +pub fn decode_pgm(bytes: &[u8]) -> Option<(usize, usize, Vec)> { + let mut fields = Vec::new(); + let mut rest = bytes.strip_prefix(b"P5")?; + // Three whitespace-separated fields follow the magic: width, height, maxval. Comments run + // from `#` to end of line and may appear between any of them. + while fields.len() < 3 { + let skip = rest.iter().position(|b| !b.is_ascii_whitespace())?; + rest = rest.get(skip..)?; + if rest.first() == Some(&b'#') { + let end = rest.iter().position(|&b| b == b'\n')?; + rest = rest.get(end.checked_add(1)?..)?; + continue; + } + let end = rest.iter().position(u8::is_ascii_whitespace)?; + let field = rest.get(..end)?; + fields.push(std::str::from_utf8(field).ok()?.parse::().ok()?); + rest = rest.get(end..)?; + } + let (&width, &height, &maxval) = (fields.first()?, fields.get(1)?, fields.get(2)?); + if maxval != 255 { + return None; + } + // Exactly one whitespace byte separates the header from the data. + let pixels = rest.get(1..)?; + let wanted = width.checked_mul(height)?; + (pixels.len() >= wanted).then(|| (width, height, pixels.get(..wanted).unwrap_or_default().to_vec())) +} + +/// Decode the netpbm `P7` RGB_ALPHA image `pdfcraft-cli render --out x.pam` writes, flattening it +/// to grayscale the way a viewer would show it: composited over white, then luminance. +/// +/// The alpha is premultiplied, which is why the composite is `channel + white * (1 - alpha)` +/// rather than the usual `mix`. A page is normally opaque, but a transparent one must not come out +/// darker here than it looks on screen, or the comparison blames the renderer for the harness. +pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec)> { + let header_end = find(bytes, b"ENDHDR\n")?.checked_add(b"ENDHDR\n".len())?; + let header = std::str::from_utf8(bytes.get(..header_end)?).ok()?; + if !header.starts_with("P7") { + return None; + } + let field = |name: &str| -> Option { header.lines().find_map(|l| l.strip_prefix(name)?.trim().parse::().ok()) }; + let (width, height) = (field("WIDTH")?, field("HEIGHT")?); + if field("DEPTH")? != 4 || field("MAXVAL")? != 255 || !header.contains("TUPLTYPE RGB_ALPHA") { + return None; + } + let data = bytes.get(header_end..)?; + let wanted = width.checked_mul(height)?.checked_mul(4)?; + if data.len() < wanted { + return None; + } + let mut out = Vec::with_capacity(width.checked_mul(height)?); + for px in data.get(..wanted)?.chunks_exact(4) { + let (&r, &g, &b, &a) = (px.first()?, px.get(1)?, px.get(2)?, px.get(3)?); + // Composite premultiplied RGB over white, in integer arithmetic that cannot overflow: + // each term is at most 255, and the sum is at most 255. + let over_white = |c: u8| u32::from(c).saturating_add(255u32.saturating_sub(u32::from(a))).min(255); + let (r, g, b) = (over_white(r), over_white(g), over_white(b)); + // Rec. 601 luma, the usual grayscale for this, rounded. Each term is at most 255*587, so + // the sum cannot overflow a u32. + let luma = (r * 299 + g * 587 + b * 114 + 500) / 1000; + out.push(u8::try_from(luma.min(255)).unwrap_or(255)); + } + Some((width, height, out)) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gray(w: usize, h: usize, fill: u8) -> Vec { + vec![fill; w * h] + } + + fn pam(w: usize, h: usize, px: &[u8]) -> Vec { + let mut out = format!("P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n").into_bytes(); + out.extend_from_slice(px); + out + } + + #[test] + fn pam_flattens_to_the_grey_a_viewer_would_show() { + // Opaque white, opaque black, opaque mid-grey. + let px = [255, 255, 255, 255, 0, 0, 0, 255, 128, 128, 128, 255]; + let (w, h, g) = decode_pam_gray(&pam(3, 1, &px)).expect("a P7 image"); + assert_eq!((w, h), (3, 1)); + assert_eq!(g, vec![255, 0, 128]); + } + + /// A fully transparent pixel is white on screen, not black. Getting this wrong would make the + /// harness report a blank margin as a difference on every page that has one. + #[test] + fn transparent_pixels_composite_over_white() { + let px = [0, 0, 0, 0, 0, 0, 0, 128]; + let (_, _, g) = decode_pam_gray(&pam(2, 1, &px)).expect("a P7 image"); + assert_eq!(g.first().copied(), Some(255), "fully transparent is white"); + assert_eq!(g.get(1).copied(), Some(127), "half-transparent black over white is mid grey"); + } + + #[test] + fn hostile_pam_headers_are_rejected() { + let px = [0u8; 8]; + assert!(decode_pam_gray(&pam(2, 1, &px)).is_some()); + let cases: Vec> = vec![ + b"".to_vec(), + b"P7\n".to_vec(), + b"P7\nWIDTH 2\nHEIGHT 1\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\n".to_vec(), // no ENDHDR + pam(2, 1, &[0u8; 4]), // truncated data + b"P6\nWIDTH 2\nHEIGHT 1\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(), + b"P7\nWIDTH 2\nHEIGHT 1\nDEPTH 3\nMAXVAL 255\nTUPLTYPE RGB\nENDHDR\n".to_vec(), // no alpha + b"P7\nWIDTH 2\nHEIGHT 1\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(), + b"P7\nWIDTH 99999999999999999999\nHEIGHT 1\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(), + ]; + for case in &cases { + assert!(decode_pam_gray(case).is_none(), "{:?}", String::from_utf8_lossy(case.get(..40).unwrap_or(case))); + } + // A declared area far larger than the data must fail on length, not allocate. + let huge = b"P7\nWIDTH 4000000000\nHEIGHT 4000000000\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(); + assert!(decode_pam_gray(&huge).is_none()); + } + + #[test] + fn identical_images_do_not_differ() { + let buf = gray(16, 16, 128); + let a = Gray::new(16, 16, &buf).expect("sized"); + let d = compare(a, a, 0).expect("same size"); + assert_eq!(d.differing, 0); + assert_eq!(d.max_delta, 0); + assert_eq!(d.fraction(), 0.0); + assert_eq!(d.total, 256); + } + + /// The whole point: a one-pixel shift of a stem is forgiven, because every moved pixel finds + /// its match next door. This is what makes the number mean something across renderers. + #[test] + fn a_one_pixel_shift_is_forgiven() { + let (w, h) = (9usize, 9usize); + let mut left = gray(w, h, 255); + let mut right = gray(w, h, 255); + for y in 0..h { + left[y * w + 4] = 0; // a vertical stem + right[y * w + 5] = 0; // the same stem, one pixel over + } + let a = Gray::new(w, h, &left).expect("sized"); + let b = Gray::new(w, h, &right).expect("sized"); + let d = compare(a, b, 8).expect("same size"); + assert_eq!(d.differing, 0, "a one-pixel shift must not count as a difference"); + // ...but it is still visible as a large single-pixel delta, which is the honest signal. + assert_eq!(d.max_delta, 255); + } + + /// And the point of the point: content that is simply absent is *not* forgiven, however + /// tolerant the neighbourhood test is. + #[test] + fn missing_content_is_not_forgiven() { + let (w, h) = (16usize, 16usize); + let blank = gray(w, h, 255); + let mut drawn = blank.clone(); + // A filled 6x6 block: its interior is further than one pixel from any white pixel. + for y in 5..11 { + for x in 5..11 { + drawn[y * w + x] = 0; + } + } + let a = Gray::new(w, h, &blank).expect("sized"); + let b = Gray::new(w, h, &drawn).expect("sized"); + let d = compare(a, b, 8).expect("same size"); + // Scanning the blank against the drawn image forgives the block's border, whose + // neighbourhood still reaches white, and counts only its 4x4 interior. Scanning the other + // way counts all 36 black pixels, since a blank image offers no dark match anywhere. The + // worse of the two is reported, so the whole block is -- which is the honest answer. + assert_eq!(d.differing, 36, "every pixel of a missing block must be counted"); + assert!(d.fraction() > 0.0); + } + + /// A three-pixel shift is beyond the 3x3 window and is reported. + #[test] + fn a_shift_wider_than_the_window_is_reported() { + let (w, h) = (16usize, 16usize); + let mut left = gray(w, h, 255); + let mut right = gray(w, h, 255); + for y in 0..h { + for x in 4..8 { + left[y * w + x] = 0; + } + for x in 11..15 { + right[y * w + x] = 0; + } + } + let a = Gray::new(w, h, &left).expect("sized"); + let b = Gray::new(w, h, &right).expect("sized"); + assert!(compare(a, b, 8).expect("same size").differing > 0); + } + + #[test] + fn tolerance_absorbs_small_differences_only() { + let flat = gray(8, 8, 100); + let near = gray(8, 8, 105); + let far = gray(8, 8, 200); + let a = Gray::new(8, 8, &flat).expect("sized"); + assert_eq!(compare(a, Gray::new(8, 8, &near).expect("sized"), 8).expect("ok").differing, 0); + assert_eq!(compare(a, Gray::new(8, 8, &far).expect("sized"), 8).expect("ok").differing, 64); + // Tolerance is inclusive at the boundary. + assert_eq!(compare(a, Gray::new(8, 8, &near).expect("sized"), 5).expect("ok").differing, 0); + assert_eq!(compare(a, Gray::new(8, 8, &near).expect("sized"), 4).expect("ok").differing, 64); + } + + /// Comparison is symmetric: neither argument order flatters one renderer. + #[test] + fn comparison_is_symmetric() { + let (w, h) = (12usize, 12usize); + let mut thin = gray(w, h, 255); + let mut thick = gray(w, h, 255); + for y in 0..h { + thin[y * w + 6] = 0; + for x in 4..9 { + thick[y * w + x] = 0; + } + } + let a = Gray::new(w, h, &thin).expect("sized"); + let b = Gray::new(w, h, &thick).expect("sized"); + let forward = compare(a, b, 8).expect("ok"); + let backward = compare(b, a, 8).expect("ok"); + assert_eq!(forward, backward, "argument order must not change the verdict"); + assert!(forward.differing > 0, "a 1px stem against a 5px bar is a real difference"); + } + + #[test] + fn different_sizes_are_an_error_not_a_score() { + let small = gray(4, 4, 0); + let large = gray(8, 8, 0); + let err = compare(Gray::new(4, 4, &small).expect("sized"), Gray::new(8, 8, &large).expect("sized"), 0).expect_err("mismatch"); + assert_eq!(err, SizeMismatch { a: (4, 4), b: (8, 8) }); + assert!(err.to_string().contains("4x4"), "{err}"); + } + + #[test] + fn a_buffer_that_is_not_the_stated_size_is_rejected() { + let buf = gray(4, 4, 0); + assert!(Gray::new(4, 4, &buf).is_some()); + assert!(Gray::new(5, 4, &buf).is_none()); + assert!(Gray::new(4, 5, &buf).is_none()); + assert!(Gray::new(usize::MAX, usize::MAX, &buf).is_none(), "the area must not overflow into a match"); + // A zero-sized image is representable and compares as no difference. + let empty: [u8; 0] = []; + let z = Gray::new(0, 0, &empty).expect("zero is a valid size"); + assert_eq!(compare(z, z, 0).expect("ok").fraction(), 0.0); + } + + #[test] + fn pgm_decodes_the_shape_pdftoppm_writes() { + let mut bytes = b"P5\n4 2\n255\n".to_vec(); + bytes.extend_from_slice(&[0, 1, 2, 3, 4, 5, 6, 7]); + let (w, h, px) = decode_pgm(&bytes).expect("a P5 image"); + assert_eq!((w, h), (4, 2)); + assert_eq!(px, vec![0, 1, 2, 3, 4, 5, 6, 7]); + } + + #[test] + fn pgm_handles_comments_and_odd_whitespace() { + let mut bytes = b"P5\n# written by pdftoppm\n 4\t2\n255 ".to_vec(); + bytes.extend_from_slice(&[9; 8]); + let (w, h, px) = decode_pgm(&bytes).expect("a commented P5 image"); + assert_eq!((w, h, px.len()), (4, 2, 8)); + } + + /// Malformed or hostile headers return `None` rather than panicking or inventing a size. + #[test] + fn hostile_pgm_headers_are_rejected() { + let cases: &[&[u8]] = &[ + b"", + b"P5", + b"P6\n4 2\n255\n", // colour, not grayscale + b"P5\n4 2\n65535\n", // 16-bit: would be read as twice the pixels + b"P5\n4 2\n255\n\x00", // truncated data + b"P5\n-4 2\n255\n", // negative + b"P5\n99999999999999999999 2\n255\n", + b"P5\n4\n", // header cut short + b"P5\n#unterminated comment", + ]; + for case in cases { + assert!(decode_pgm(case).is_none(), "{:?}", String::from_utf8_lossy(case)); + } + // A huge declared area must not allocate: it fails on the data length check. + let huge = b"P5\n4000000000 4000000000\n255\n".to_vec(); + assert!(decode_pgm(&huge).is_none()); + } +} diff --git a/crates/organize/Cargo.toml b/crates/organize/Cargo.toml index e759a322d..78507197a 100644 --- a/crates/organize/Cargo.toml +++ b/crates/organize/Cargo.toml @@ -14,4 +14,5 @@ pdfcraft-cos = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-oracle = { workspace = true } hayro-syntax = { workspace = true } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index e07af72a1..9f493e296 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2025,9 +2025,12 @@ fn extract_still_prunes_separate_resources_when_many_pages_share_a_type3_font() assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "one shared font's direct resource graph is protected once"); } +// Both sides of the comparison go through the same pdftoppm, so the assertion is about our two +// documents rather than about Poppler's output, and no Poppler version can change the verdict. +// It skips where pdftoppm is absent and fails where PDFCRAFT_ORACLES_REQUIRED is set. #[test] -#[ignore = "requires the external Poppler pdftoppm oracle"] fn extract_type3_shared_xobjects_matches_poppler_pixels() { + let pdftoppm = pdfcraft_oracle::or_skip!(pdfcraft_oracle::pdftoppm()); let dir = std::env::temp_dir().join(format!("pdfcraft-type3-extract-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let fixtures = [ @@ -2042,7 +2045,7 @@ fn extract_type3_shared_xobjects_matches_poppler_pixels() { let render = |doc: &Document, name: &str| { let path = dir.join(format!("{i}-{name}.pdf")); std::fs::write(&path, write_full(doc, &SaveOptions::default()).unwrap()).unwrap(); - let output = std::process::Command::new("pdftoppm").args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap(); + let output = pdftoppm.command().args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap(); assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr)); output.stdout diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 4aee20bfc..214c07f80 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -19,3 +19,4 @@ pdfcraft-annot = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 7fcbc4305..09723b763 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2,32 +2,12 @@ use std::sync::Arc; use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; +use pdfcraft_testkit::{Pdf, stream_obj as stream}; use super::*; -fn stream(dict: &str, data: &[u8]) -> Vec { - let mut v = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); - v.extend_from_slice(data); - v.extend_from_slice(b"\nendstream"); - v -} - fn pdf(objs: Vec>) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - out.extend_from_slice(o); - out.extend_from_slice(b"\nendobj\n"); - } - let x = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); - Document::open(Arc::new(out)).unwrap() + Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap() } /// A font where every glyph is 500 units wide (so a 10 pt glyph advances 5 pt). @@ -252,7 +232,7 @@ fn vertical_writing_text_under_a_mark_fails_closed() { #[test] fn images_are_removed_or_have_their_pixels_cleared() { // Image 1 is fully covered; image 2 (4×1 gray, all white) is half covered. - let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 255, 255, 255]); + let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]); let src = b"q 10 0 0 10 10 10 cm /Im1 Do Q q 40 0 0 10 100 100 cm /Im2 Do Q"; let mut doc = one_page(src, "/XObject << /Im1 7 0 R /Im2 7 0 R >>", vec![img]); mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0], [95.0, 95.0, 120.0, 115.0]], ""); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 8d54c3d50..a3af3876b 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -365,6 +365,13 @@ pub struct FontInfo { pub embedded: bool, pub subset: bool, pub encoding: Option, + /// 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, + /// 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, } /// 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::>())) + } + + #[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"); + } +} diff --git a/crates/render/tests/jbig2_invariants.rs b/crates/render/tests/jbig2_invariants.rs new file mode 100644 index 000000000..436782573 --- /dev/null +++ b/crates/render/tests/jbig2_invariants.rs @@ -0,0 +1,100 @@ +//! Every JBIG2 conformance fixture in the corpus encodes the same picture. +//! +//! The 96 `bitmap-*.pdf` files are a conformance suite: one small drawing, encoded through every +//! corner of JBIG2 — generic regions with each template and with custom AT pixels, typical +//! prediction (TPGDON, TPGRON), refinement, halftone regions with and without skip, symbol +//! dictionaries with Huffman tables, MMR, composition operators, random access, unknown-height +//! segments. The features change how the bytes are compressed. They do not change the picture. +//! +//! So a correct decoder renders all 96 identically, and any file that comes out different is a +//! decoder bug. That is a real test with no reference image and no external oracle: the suite +//! checks itself. +//! +//! It is worth having because the property settled a question the render oracle raised and could +//! not answer. `cargo xtask render-oracle` put four JBIG2 fixtures at the top of its disagreement +//! list against Poppler, and the natural reading was that `hayro-jbig2`, which carries local +//! patches, had a bug. The opposite is true. Measured over this suite: **we render 1 distinct +//! image across all 96; Poppler renders 8**, drawing nothing at all for the seven halftone-skip +//! and symbol fixtures and mis-decoding TPGRON refinement. An oracle is not ground truth. This +//! invariant is. +//! +//! Runs over `corpus/pdfjs/test/pdfs` when present (`cargo xtask corpus` fetches and verifies it); +//! a no-op when absent, so CI without the corpus stays green. The expectation that every fixture +//! holds the same picture is a property of the **pinned** corpus; whoever re-pins it to a newer +//! pdf.js should re-check this test rather than assume it still holds. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +/// `corpus/pdfjs/test/pdfs`, or `None` when nothing has been fetched. +/// +/// Panics when the checkout exists without the stamp `cargo xtask corpus` writes on a successful +/// verification: an unverified corpus must fail loudly rather than skip, as in `pdfcraft-cos`'s +/// corpus tests. +fn corpus() -> Option { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs"); + if !dir.is_dir() { + return None; + } + assert!( + dir.join(".pdfcraft-corpus.json").is_file(), + "{} exists but has not been verified against the pin; run `cargo xtask corpus`", + dir.display() + ); + Some(dir.join("test/pdfs")) +} + +/// Page 1 as premultiplied RGBA at 1:1, or `None` when it could not be rendered. +fn render(path: &std::path::Path) -> Option<(u32, u32, Vec)> { + let bytes = std::fs::read(path).ok()?; + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig { reject_oversize: true, ..Default::default() }); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + page.error.is_none().then_some((page.width, page.height, page.rgba.to_vec())) +} + +#[test] +#[ignore = "needs corpus/; run with --ignored"] +fn every_jbig2_conformance_fixture_decodes_to_the_same_picture() { + let Some(dir) = corpus() else { + eprintln!("corpus not fetched; skipping (cargo xtask corpus)"); + return; + }; + let mut fixtures: Vec = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "pdf") && p.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.starts_with("bitmap"))) + .collect(); + fixtures.sort(); + + // Group by what each one drew. One group means every feature decoded to the same picture. + let mut groups: BTreeMap<(u32, u32, Vec), Vec> = BTreeMap::new(); + let mut unrenderable = Vec::new(); + for path in &fixtures { + let name = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + match render(path) { + Some(image) => groups.entry(image).or_default().push(name), + None => unrenderable.push(name), + } + } + + assert!(fixtures.len() >= 90, "expected the JBIG2 conformance fixtures in the pinned corpus, found {}", fixtures.len()); + assert!(unrenderable.is_empty(), "every fixture must render: {unrenderable:?}"); + + if groups.len() > 1 { + // Name the odd ones out: the largest group is what the picture should be, so anything + // else is the bug, and saying which files they are is the whole value of the failure. + let mut sized: Vec<(usize, &Vec)> = groups.values().map(|names| (names.len(), names)).collect(); + sized.sort_by_key(|(n, _)| std::cmp::Reverse(*n)); + let report: Vec = sized.iter().skip(1).map(|(n, names)| format!("{n} file(s): {names:?}")).collect(); + panic!( + "JBIG2 coding features must not change the picture, but {} of {} fixtures decoded differently:\n {}", + fixtures.len().saturating_sub(sized.first().map_or(0, |(n, _)| *n)), + fixtures.len(), + report.join("\n ") + ); + } +} diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..4237f341b --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pdfcraft-testkit" +description = "Synthetic PDF fixtures for PdfCraft's tests: one way to write the bytes, so the tests differ only where they mean to" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/README.md b/crates/testkit/README.md new file mode 100644 index 000000000..44388b1b2 --- /dev/null +++ b/crates/testkit/README.md @@ -0,0 +1,57 @@ +# pdfcraft-testkit + +Synthetic PDF fixtures: **one way to write the bytes, so tests differ only where they mean to.** + +- **Layer:** test support (`Class::Testkit` in `xtask/src/layers.rs`). A dev-dependency only, never + compiled into the product. +- **No workspace dependencies**, deliberately. The crates at the bottom of the layering (`cos`, + `geom`, `filters`, `crypt`, `arlington`) can use it in their own tests without a cycle, which is + also why it hands back bytes rather than a parsed `Document`. + +## Why it exists + +55 files hand-rolled the same serialiser — number the objects, record each one's offset, emit a +classic cross-reference table and a trailer — **111 times over**. The object *bodies* are properly +the business of the crate under test. The envelope around them is not, and every copy is a place +for a fixture to be quietly wrong in a way that makes a test prove less than it appears to. + +## API + +```rust +use pdfcraft_testkit::Pdf; + +let bytes = Pdf::new() + .version("1.4") // default 1.7 + .obj("<< /Type /Catalog /Pages 2 0 R >>") // becomes 1 0 R + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", b"BT /F1 12 Tf (hi) Tj ET") // /Length computed + .trailer_entry("/Info 5 0 R") + .build(); +``` + +Objects are numbered from 1 in the order they are added, all at generation 0; the trailer always +names `1 0 R` as `/Root` and carries the right `/Size`. `next_obj()` gives the number the next +object will get, for forward references. `stream_obj(dict, data)` builds a stream body on its own, +for callers that assemble their object list first. + +## Byte-identical by construction + +`the_builder_is_byte_identical_to_the_serialiser_it_replaces` keeps a verbatim copy of the +hand-rolled serialiser and asserts the builder agrees with it, across versions and trailer +variations. Migrating a crate must not change a single byte of its fixtures — otherwise the test +that fixture feeds silently starts testing something else. + +The one intended difference: an empty stream dictionary gives `<< /Length N >>` rather than the +doubled space `<< /Length N >>` that some copies produced. Parsers accept both. + +## Migration status + +Three crates are migrated (`cos`, `measure`, `redact`), taking the count from 111 copies in 55 +files to **108 in 53**. The rest are follow-ups, deliberately not done in one sweep: `ui-egui` +alone accounts for 27 of those files and is the most contested area in the repository, so a single +large mechanical change there would rot before it landed. + +Not every copy should move. A test that is *about* a malformed or unusual cross-reference table +needs to write its own bytes; this builder is for the fixtures that are merely scaffolding for +something else. diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..c9f5e55ef --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,253 @@ +//! Synthetic PDF fixtures: one way to write the bytes, so tests differ only where they mean to. +//! +//! 55 files across the workspace hand-rolled the same serialiser — number the objects, record each +//! one's offset, emit a classic cross-reference table and a trailer — 111 times over. The object +//! *bodies* are properly the business of the crate under test; the envelope around them is not, +//! and every copy of it is a place for a fixture to be subtly wrong in a way that makes a test +//! prove less than it looks like it proves. +//! +//! [`Pdf`] writes that envelope. It is deliberately dependency-free, so the crates at the bottom of +//! the layering (`cos`, `geom`, `filters`, `crypt`) can use it in their own tests without a cycle, +//! and it hands back bytes rather than a parsed document for the same reason. +//! +//! ``` +//! use pdfcraft_testkit::Pdf; +//! let bytes = Pdf::new() +//! .obj("<< /Type /Catalog /Pages 2 0 R >>") +//! .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") +//! .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>") +//! .stream("", b"BT /F1 12 Tf (hi) Tj ET") +//! .build(); +//! assert!(bytes.starts_with(b"%PDF-1.7\n")); +//! assert!(bytes.ends_with(b"%%EOF\n")); +//! ``` +//! +//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency +//! only, never compiled into the product. + +/// A small PDF with a classic cross-reference table, built one object at a time. +/// +/// Objects are numbered from 1 in the order they are added, all at generation 0, so the first +/// object added is `1 0 R`. The trailer names `1 0 R` as `/Root`, which is what every caller of +/// this builder wants; [`Pdf::trailer_entry`] adds the rest. +#[derive(Debug, Clone)] +pub struct Pdf { + version: String, + objects: Vec>, + trailer: Vec, +} + +impl Default for Pdf { + fn default() -> Self { + Self::new() + } +} + +impl Pdf { + /// An empty PDF 1.7 file. Add objects with [`Pdf::obj`]. + pub fn new() -> Self { + Self { version: "1.7".to_owned(), objects: Vec::new(), trailer: Vec::new() } + } + + /// Set the header version, as it appears after `%PDF-` (for example `"1.4"`). + #[must_use] + pub fn version(mut self, version: &str) -> Self { + self.version = version.to_owned(); + self + } + + /// Append one object body — everything between `N 0 obj` and `endobj`. + #[must_use] + pub fn obj(mut self, body: impl AsRef<[u8]>) -> Self { + self.objects.push(body.as_ref().to_vec()); + self + } + + /// Append several object bodies, in order. + #[must_use] + pub fn objs, T: AsRef<[u8]>>(mut self, bodies: I) -> Self { + self.objects.extend(bodies.into_iter().map(|b| b.as_ref().to_vec())); + self + } + + /// Append a stream object: `dict` holds any entries besides `/Length`, which is computed. + #[must_use] + pub fn stream(self, dict: &str, data: impl AsRef<[u8]>) -> Self { + self.obj(stream_obj(dict, data)) + } + + /// Add an entry to the trailer dictionary, such as `"/Info 9 0 R"`. `/Size` and `/Root` are + /// always written. + #[must_use] + pub fn trailer_entry(mut self, entry: &str) -> Self { + self.trailer.push(entry.to_owned()); + self + } + + /// The number the next object added will get, so a caller can refer forward to it. + /// + /// ``` + /// # use pdfcraft_testkit::Pdf; + /// let mut p = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>"); + /// assert_eq!(p.next_obj(), 2); + /// ``` + pub fn next_obj(&self) -> usize { + self.objects.len().saturating_add(1) + } + + /// Serialise the file. + pub fn build(&self) -> Vec { + let mut out = format!("%PDF-{}\n", self.version).into_bytes(); + let mut offsets = Vec::with_capacity(self.objects.len()); + for (i, body) in self.objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i.saturating_add(1)).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let startxref = out.len(); + let size = self.objects.len().saturating_add(1); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + let extra = self.trailer.iter().map(|e| format!(" {e}")).collect::(); + out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R{extra} >>\nstartxref\n{startxref}\n%%EOF\n").as_bytes()); + out + } +} + +/// A stream object body with `/Length` filled in, for callers assembling object lists themselves. +/// +/// `dict` holds any entries besides `/Length`; an empty one gives `<< /Length N >>` rather than +/// the doubled space the hand-rolled copies produced, which parsers accept but readers trip over. +pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec { + let data = data.as_ref(); + let dict = dict.trim(); + let sep = if dict.is_empty() { "" } else { " " }; + let mut out = format!("<<{sep}{dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The serialiser the workspace had 111 copies of, kept here verbatim as the thing [`Pdf`] has + /// to agree with. Migrating a crate to the builder must not change a single byte of its + /// fixtures, or the test that fixture feeds silently starts testing something else. + fn hand_rolled(version: &str, objs: &[Vec], trailer_extra: &str) -> Vec { + let mut out = format!("%PDF-{version}\n").into_bytes(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{trailer_extra} >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + fn sample_objects() -> Vec> { + vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), + stream_obj("", b"BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"), + ] + } + + #[test] + fn the_builder_is_byte_identical_to_the_serialiser_it_replaces() { + let objs = sample_objects(); + for version in ["1.4", "1.7", "2.0"] { + let built = Pdf::new().version(version).objs(objs.clone()).build(); + assert_eq!(built, hand_rolled(version, &objs, ""), "version {version}"); + } + // ...including the trailer entries the copies varied on. + let built = Pdf::new().objs(objs.clone()).trailer_entry("/Info 5 0 R").build(); + assert_eq!(built, hand_rolled("1.7", &objs, " /Info 5 0 R")); + } + + /// Offsets must point at the `N 0 obj` that starts each object, counted from the file's first + /// byte. A fixture whose table is wrong tests the repair path, not the path it meant to. + #[test] + fn every_offset_in_the_table_points_at_its_object() { + let bytes = Pdf::new().objs(sample_objects()).build(); + let text = String::from_utf8_lossy(&bytes); + let table = text.split("xref\n0 ").nth(1).expect("an xref table"); + let rows: Vec<&str> = table.lines().skip(1).take_while(|l| l.ends_with(" n ") || l.ends_with(" f ")).collect(); + assert_eq!(rows.len(), 5, "free entry plus four objects: {rows:?}"); + for (i, row) in rows.iter().enumerate().skip(1) { + let offset: usize = row.split_whitespace().next().and_then(|n| n.parse().ok()).expect("an offset"); + let expected = format!("{i} 0 obj\n"); + assert!(bytes.get(offset..).is_some_and(|rest| rest.starts_with(expected.as_bytes())), "object {i} at {offset}"); + } + } + + /// `/Size` is one more than the object count (object 0 is the free head), and `startxref` + /// points at the table. + #[test] + fn the_trailer_agrees_with_the_body() { + let bytes = Pdf::new().objs(sample_objects()).build(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Size 5"), "{text}"); + let startxref: usize = text.rsplit("startxref\n").next().and_then(|t| t.lines().next()).and_then(|n| n.parse().ok()).expect("startxref"); + assert!(bytes.get(startxref..).is_some_and(|rest| rest.starts_with(b"xref\n")), "startxref {startxref}"); + } + + #[test] + fn stream_length_counts_the_data_not_the_keywords() { + let obj = stream_obj("/Filter /FlateDecode", b"abc"); + assert_eq!(obj, b"<< /Filter /FlateDecode /Length 3 >>\nstream\nabc\nendstream".to_vec()); + // Binary data, including NUL and bytes that are not UTF-8, survives untouched. + let data = [0u8, 0xFF, b'\n', 0x80]; + let obj = stream_obj("", data); + assert!(obj.ends_with(b"stream\n\0\xff\n\x80\nendstream"), "{obj:?}"); + // An empty dict gives one space, not two. + assert!(obj.starts_with(b"<< /Length 4 >>"), "{obj:?}"); + assert_eq!(stream_obj(" /Type /XObject ", b""), stream_obj("/Type /XObject", b"")); + } + + /// An empty file is still a parseable shape rather than something that panics or truncates. + #[test] + fn a_file_with_no_objects_is_still_well_formed() { + let bytes = Pdf::new().build(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.7\n"), "{text}"); + assert!(text.contains("xref\n0 1\n0000000000 65535 f \n"), "{text}"); + assert!(text.contains("/Size 1 /Root 1 0 R"), "{text}"); + assert!(text.ends_with("%%EOF\n"), "{text}"); + } + + #[test] + fn next_obj_tracks_what_has_been_added() { + let p = Pdf::new(); + assert_eq!(p.next_obj(), 1); + let p = p.obj("<< >>").obj("<< >>"); + assert_eq!(p.next_obj(), 3); + // Forward references are the reason this exists: the page can name a stream not yet added. + let n = p.next_obj(); + let bytes = p.obj(format!("<< /Contents {n} 0 R >>")).build(); + assert!(String::from_utf8_lossy(&bytes).contains("/Contents 3 0 R")); + } + + /// Hostile and awkward object bodies must not panic or corrupt the offsets. + #[test] + fn odd_object_bodies_keep_the_table_honest() { + let bodies: Vec> = vec![Vec::new(), b"\n".to_vec(), vec![0u8; 1024], "<< /T (\u{1F4A5}) >>".as_bytes().to_vec(), b"endobj".to_vec()]; + let bytes = Pdf::new().objs(bodies.clone()).build(); + assert_eq!(bytes, hand_rolled("1.7", &bodies, "")); + // The offsets still land on their objects even when a body contains `endobj` itself. + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Size 6"), "{text}"); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 780187816..9617bf67d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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, bool)>(id) != Some(state); + let changed = data.get_temp::<(Option, 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 { + 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, 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). diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 7d7b6b9c5..a8deee258 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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, count: usize, forward: bool) -> Option { + 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); + } } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 9b1b35b19..dc7226ef6 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -664,7 +664,7 @@ impl PdfCraftApp { self.notify_tr("The document was closed before it could be saved."); return false; }; - let bytes = match self.session.save_bytes(id) { + let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) { Ok(b) => b, Err(e) => { self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); diff --git a/crates/ui-egui/src/file_watch.rs b/crates/ui-egui/src/file_watch.rs index da8521c86..e0e653e0e 100644 --- a/crates/ui-egui/src/file_watch.rs +++ b/crates/ui-egui/src/file_watch.rs @@ -68,11 +68,17 @@ pub struct Watched { pub path: String, /// The version PdfCraft last read or wrote (`None`: the first one the watcher sees). pub known: Option, + /// Which reading or writing of the file by PdfCraft this is for (an open, a save, Save As): + /// a version the watcher found for an earlier one is older than the document is now. + pub epoch: u64, } /// A new version of a watched file, read and ready to open. pub struct Changed { pub doc: DocId, + /// The file it was read from, and the epoch it was watched for (see [`Watched`]). + pub path: String, + pub epoch: u64, pub stamp: Stamp, pub bytes: Arc>, } @@ -81,6 +87,7 @@ pub struct Changed { struct Entry { doc: DocId, path: String, + epoch: u64, known: Option, /// A changed version seen once, waiting to be seen unchanged again. pending: Option, @@ -101,7 +108,13 @@ impl Watcher { .into_iter() .map(|w| { let prev = old.iter().find(|e| e.doc == w.doc && e.path == w.path); - Entry { known: w.known.or(prev.and_then(|e| e.known)), pending: prev.and_then(|e| e.pending), doc: w.doc, path: w.path } + Entry { + known: w.known.or(prev.and_then(|e| e.known)), + pending: prev.and_then(|e| e.pending), + doc: w.doc, + path: w.path, + epoch: w.epoch, + } }) .collect(); } @@ -132,7 +145,7 @@ impl Watcher { match read_whole_pdf(&e.path, now) { Version::Ready(bytes) => { e.known = Some(now); - found.push(Changed { doc: e.doc, stamp: now, bytes }); + found.push(Changed { doc: e.doc, path: e.path.clone(), epoch: e.epoch, stamp: now, bytes }); } // Not a whole PDF in this version (a build that failed or paused): wait for the // next one. @@ -234,16 +247,22 @@ pub struct FileWatch { inline: Watcher, /// The version of each document's file that PdfCraft last read or wrote. known: HashMap, + /// How many times PdfCraft has read or written each document's file: a version the watcher + /// found before the latest one is older than the document in memory, and is not applied + /// (#1802). + epoch: HashMap, /// The notice bar's Reload was pressed for this document. pub reload_request: Option, } impl FileWatch { - /// PdfCraft read or wrote `path` for `doc`: that version is not a change. + /// PdfCraft read or wrote `path` for `doc`: that version is not a change, and anything the + /// watcher found before it is stale. pub fn known_now(&mut self, doc: DocId, path: &str) { if let Some(stamp) = Stamp::at(path) { self.known.insert(doc, stamp); } + self.bump(doc); } /// Remember `stamp`, read before the file was, for `doc`. @@ -251,15 +270,25 @@ impl FileWatch { if let Some(stamp) = stamp { self.known.insert(doc, stamp); } + self.bump(doc); + } + + fn bump(&mut self, doc: DocId) { + let epoch = self.epoch.entry(doc).or_default(); + *epoch = epoch.wrapping_add(1); + } + + fn epoch(&self, doc: DocId) -> u64 { + self.epoch.get(&doc).copied().unwrap_or(0) } } impl PdfCraftApp { /// The documents to watch: every open one with a file, while the preference is on. - fn watched(&self) -> impl Iterator)> { + fn watched(&self) -> impl Iterator, u64)> { self.views.iter().filter(|_| self.reload_changed_files).filter_map(|v| { let path = self.session.get(v.id)?.path.as_deref()?; - Some((v.id, path, self.watch.known.get(&v.id).copied())) + Some((v.id, path, self.watch.known.get(&v.id).copied(), self.watch.epoch(v.id))) }) } @@ -270,12 +299,15 @@ impl PdfCraftApp { self.reload_from_disk(id); } let mut sent = self.watch.sent.iter(); - let unchanged = self.watched().all(|(doc, path, known)| sent.next().is_some_and(|w| w.doc == doc && w.path == path && w.known == known)) + let unchanged = self + .watched() + .all(|(doc, path, known, epoch)| sent.next().is_some_and(|w| w.doc == doc && w.path == path && w.known == known && w.epoch == epoch)) && sent.next().is_none(); if !unchanged { - let list: Vec = self.watched().map(|(doc, path, known)| Watched { doc, path: path.to_owned(), known }).collect(); + let list: Vec = self.watched().map(|(doc, path, known, epoch)| Watched { doc, path: path.to_owned(), known, epoch }).collect(); let open: Vec = self.views.iter().map(|v| v.id).collect(); self.watch.known.retain(|doc, _| open.contains(doc)); + self.watch.epoch.retain(|doc, _| open.contains(doc)); if self.send_to_watcher(list.clone()) { self.watch.sent = list; } @@ -321,6 +353,13 @@ impl PdfCraftApp { /// changes. fn file_changed(&mut self, c: Changed) { let Some(i) = self.views.iter().position(|v| v.id == c.doc) else { return }; + // Found for an earlier reading or writing of the file, or for another file than the + // document's now (a save, Save As or a reopen came between): older than the document in + // memory, so it is not applied over it (#1802). The watcher was told the newer version. + let same_file = self.session.get(c.doc).and_then(|d| d.path.as_deref()) == Some(c.path.as_str()); + if !same_file || self.watch.epoch(c.doc) != c.epoch { + return; + } // PdfCraft wrote this version itself (a save), or it was handled already. if self.watch.known.get(&c.doc) == Some(&c.stamp) { return; @@ -375,3 +414,133 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use egui_kittest::Harness; + + use super::*; + use crate::SaveTarget; + + /// An `n`-page PDF with a proper xref table. + fn fixture(n: usize) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" "))); + for _ in 0..n { + objs.push("<< /Type /Page /Parent 2 0 R >>".into()); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + /// A fresh temporary folder with `thesis.pdf` in it, removed when dropped. + struct TempDir(PathBuf); + + impl TempDir { + fn new(test: &str, bytes: &[u8]) -> (Self, String) { + let dir = std::env::temp_dir().join(format!("pdfcraft-watch-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("thesis.pdf").to_string_lossy().into_owned(); + std::fs::write(&path, bytes).unwrap(); + (Self(dir), path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Rewrite `path` as a build does, with a later modification time. + fn rebuild(path: &str, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + let file = std::fs::File::options().write(true).open(path).unwrap(); + file.set_modified(SystemTime::now() + Duration::from_secs(60)).unwrap(); + } + + fn harness(path: &str) -> Harness<'static, PdfCraftApp> { + let path = path.to_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.open_path(&path); + app + }); + h.run_steps(4); + h + } + + /// Version B of the open file, found by the watcher but not yet applied: queued as the worker + /// thread queues what it finds between frames. + fn find_new_version(app: &mut PdfCraftApp, path: &str, bytes: &[u8]) -> Vec { + rebuild(path, bytes); + // One look sees the change, the next sees it settled and reads it. + let (mut found, _) = app.watch.inline.tick(); + found.extend(app.watch.inline.tick().0); + assert_eq!(found.len(), 1, "the watcher found the new version"); + found + } + + fn doc<'a>(h: &'a Harness<'static, PdfCraftApp>) -> &'a pdfcraft_engine::Document { + let app = h.state(); + app.session.get(app.views[0].id).unwrap() + } + + /// #1802: the watcher found version B of the file, then the user saved version C over it + /// before B was applied. B is older than the document now: it is not applied, the undo history + /// stays, and C stays on disk. + #[test] + fn a_version_found_before_a_save_is_not_applied_over_it() { + let (_dir, path) = TempDir::new("stale-save", &fixture(2)); + let mut h = harness(&path); + let stale = find_new_version(h.state_mut(), &path, &fixture(3)); + let app = h.state_mut(); + app.views[0].select_pages(&[0]); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + assert!(app.save_active(SaveTarget::InPlace), "saved version C"); + app.watch.found.lock().unwrap().extend(stale); + h.run_steps(3); + let d = doc(&h); + assert_eq!(d.info.pages.len(), 2, "the stale version was not applied over the saved one"); + assert!(d.can_undo().is_some() && !d.dirty, "the saved document and its history stay"); + assert_eq!(Stamp::at(&path), h.state().watch.known.get(&h.state().views[0].id).copied(), "C is still on disk"); + } + + /// #1802: version B found under the old path, then Save As to another file: B is not applied + /// to the document under its new path. + #[test] + fn a_version_found_before_save_as_is_not_applied_to_the_new_file() { + let (dir, path) = TempDir::new("stale-save-as", &fixture(2)); + let mut h = harness(&path); + let stale = find_new_version(h.state_mut(), &path, &fixture(3)); + let new_path = dir.0.join("copy.pdf").to_string_lossy().into_owned(); + let app = h.state_mut(); + app.views[0].select_pages(&[0]); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + app.save_override = Some(new_path.clone()); + assert!(app.save_active(SaveTarget::As), "saved as the new file"); + app.watch.found.lock().unwrap().extend(stale); + h.run_steps(3); + let d = doc(&h); + assert_eq!(d.path.as_deref(), Some(new_path.as_str())); + assert_eq!(d.info.pages.len(), 2, "the old file's version was not applied to the new one"); + assert!(d.can_undo().is_some() && !d.dirty, "the saved document and its history stay"); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 222865d72..a8c7d5382 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -80,6 +80,8 @@ Column select تحديد عمود Full screen mode وضع ملء الشاشة Read mode وضع القراءة + Next tab علامة التبويب التالية + Previous tab علامة التبويب السابقة Switch light / dark theme التبديل بين السمة الفاتحة والداكنة Comments panel لوحة التعليقات Form fields panel لوحة حقول النموذج diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 17a0890fa..e107408e2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -116,6 +116,8 @@ Column select Избор на колона Full screen mode Цял екран Read mode Режим на четене + Next tab Следващ раздел + Previous tab Предишен раздел Switch light / dark theme Превключи светла / тъмна тема Comments panel Панел с коментари Form fields panel Панел с полета на формуляра diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6e29c8bb..835388f40 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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í diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38ef665d1..7a418b54e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 106278f01..3a99376d6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -65,6 +65,8 @@ Column select Επιλογή στήλης Full screen mode Λειτουργία πλήρους οθόνης Read mode Λειτουργία ανάγνωσης + Next tab Επόμενη καρτέλα + Previous tab Προηγούμενη καρτέλα Switch light / dark theme Εναλλαγή φωτεινού / σκοτεινού θέματος Comments panel Πίνακας σχολίων Form fields panel Πίνακας πεδίων φόρμας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5afbe4f2e..f69380564 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 79389837d..76be27ca7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 29a56179c..d692d7014 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2560,6 +2560,8 @@ calendar weekday Tu मंगल calendar weekday We बुध calendar weekday Th गुरु calendar weekday Fr शुक्र + Next tab अगली टैब + Previous tab पिछली टैब calendar weekday Sa शनि certificate Issued to जिसे जारी किया गया certificate Issued by जिसने जारी किया diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3d6e1e3af..221ad21f9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7315bc5fe..2ad015b60 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2442,6 +2442,8 @@ calendar weekday Tu Sel calendar weekday We Rab calendar weekday Th Kam calendar weekday Fr Jum + Next tab Tab berikutnya + Previous tab Tab sebelumnya calendar weekday Sa Sab signature pad Type Ketik scroll speed Slow Lambat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3575ab374..94eeab157 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e61802c3..777410e90 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -80,6 +80,8 @@ Column select 列の選択 Full screen mode 全画面表示 Read mode 閲覧モード + Next tab 次のタブ + Previous tab 前のタブ Switch light / dark theme 明るい/暗いテーマを切り替え Comments panel コメントパネル Form fields panel フォームフィールドパネル diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 8e4a09040..2ce5830af 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -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 Форма өрістері панелі diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ac9210122..f28e85852 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2560,6 +2560,8 @@ calendar weekday Tu 화 calendar weekday We 수 calendar weekday Th 목 calendar weekday Fr 금 + Next tab 다음 탭 + Previous tab 이전 탭 calendar weekday Sa 토 certificate Issued to 발급 대상 certificate Issued by 발급자 diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ae61e396f..36c698c81 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -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ą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 310a990f7..b443c8415 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 623d69ce8..9b2a68394 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0658b5a14..c63ec0ee6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -78,6 +78,8 @@ Column select Выделение столбца Full screen mode Полноэкранный режим Read mode Режим чтения + Next tab Следующая вкладка + Previous tab Предыдущая вкладка Switch light / dark theme Переключить светлую / тёмную тему Comments panel Панель комментариев Form fields panel Панель полей формы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3feaad354..506e52ee4 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index d15365f3d..08c53e84f 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -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 ఫారం ఫీల్డ్‌ల ప్యానెల్ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 44fd05f07..eb03e7f99 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 648dc855f..eb1cea0e1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -78,6 +78,8 @@ Column select Виділення стовпця Full screen mode Повноекранний режим Read mode Режим читання + Next tab Наступна вкладка + Previous tab Попередня вкладка Switch light / dark theme Перемкнути світлу / темну тему Comments panel Панель коментарів Form fields panel Панель полів форми diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ec1fe37bb..2edd2b9ff 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2442,6 +2442,8 @@ calendar weekday Tu T3 calendar weekday We T4 calendar weekday Th T5 calendar weekday Fr T6 + Next tab Thẻ tiếp theo + Previous tab Thẻ trước calendar weekday Sa T7 signature pad Type Nhập scroll speed Slow Chậm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 391c8eda7..d13078e7a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -122,6 +122,8 @@ Column select 列选择 Full screen mode 全屏模式 Read mode 阅读模式 + Next tab 下一个标签页 + Previous tab 上一个标签页 Switch light / dark theme 切换浅色/深色主题 Comments panel 注释面板 Form fields panel 表单域面板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da45921c2..9f8856ae1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -81,6 +81,8 @@ Column select 欄選取 Full screen mode 全螢幕模式 Read mode 閱讀模式 + Next tab 下一個分頁 + Previous tab 上一個分頁 Switch light / dark theme 切換淺色/深色主題 Comments panel 註解面板 Form fields panel 表單欄位面板 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ebbd45d52..f800ad6a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1800,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))) } diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index ddbb6738a..a8b9d88f0 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -590,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"); + } +} diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 6dbb3c246..4c1bdffbe 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1186,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::>(), 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::>(), [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::>(), [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::>(), 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::>(), 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::>(), ids); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9e058ab99..f9280089d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -212,10 +212,46 @@ update the numbers. signature validation against Acrobat on synthetic fixtures; an interop suite that opens every kind of output we write (encrypted ones above all, after #774) in pdf.js, MuPDF and Poppler as oracle processes. -- **Evidence:** none exists; only text extraction has an oracle (word-F1 0.98 against `pdftotext`). - Acrobat captures must stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). -- **Impact:** until it exists, every quality number here is a judgement. -- **Estimate:** counted in gap 2 (≈ 20–30 h of it). **Doc:** [target-app-parity.md](target-app-parity.md). +- **Progress (2026-10-14):** rendering now has an oracle too. `cargo xtask render-oracle` compares + page 1 of every corpus file against Poppler's `pdftoppm` and reports a disagreement rate: the + share of pixels with no near match in the other renderer's 3×3 neighbourhood, which forgives + antialiasing and subpixel placement but not missing or wrong content (`crates/oracle`, module + `pixels`). **First reading, 250 files at 72 dpi, tolerance 8: 229 compared, median 0.89%, mean + 2.16%, p90 5.35%, 25 within 0.1%.** It reports rather than gates — the corpus is not fetched on + CI, and a first reading of a borrowed renderer is a baseline to improve on, not a bar to clear. + The worst results cluster on JBIG2 `bitmap-*` files (`hayro-jbig2` carries 3 local patches) and + on ZapfDingbats, which points the renderer work at something specific for the first time. +- **Read the score as a disagreement, not a verdict (2026-10-14).** The worst file of the 250, + `bug1552113.pdf` at 84%, turned out to be Poppler's defect: it paints the link's `/Border [0 0 + 112]` as a 112-unit band over the two lines of text, and the fixture is named "this text should + be visible". Ours is the readable rendering. Anything at the top of the list needs a person to + look at both images before it is treated as a bug of ours. +- **The JBIG2 cluster was Poppler too, and the suite checks itself (2026-10-14).** The next four + worst files were JBIG2, which looked like a defect in `hayro-jbig2` and its local patches. It is + the reverse. The 96 `bitmap-*.pdf` fixtures are a conformance suite: one picture encoded through + every corner of JBIG2, so a correct decoder renders all 96 identically — a real test needing no + reference image and no oracle. **We render 1 distinct image across all 96; Poppler renders 8**, + drawing a blank page for the seven halftone-skip and symbol fixtures and mis-decoding TPGRON + refinement (its output changes when TPGRON is switched on, which a correct decoder's cannot). + Pinned as `crates/render/tests/jbig2_invariants.rs`. **Poppler is not a usable oracle for JBIG2**, + and roughly a fifth of its fixtures should be read as its bugs rather than ours. +- **Annotations are shown, and that was measured rather than argued.** Poppler synthesises an + appearance for an annotation that has none, where we draw only what the document carries (link + borders without `/AP` are recorded as not covered under `view.render-annotation-appearances`), so + showing them scores us for a known gap. Hiding them is worse, because it then scores Poppler for + every appearance we *do* draw: over the same 250 files, shown gives mean 2.16% / p90 5.35% / 25 + files within 0.1%, hidden gives 2.18% / 5.55% / 22, with `annotation-tx3.pdf` jumping to 45.64%. + The biases nearly cancel; `--hide-annotations` tells the two apart for one file at a time. +- **Evidence:** text extraction (word-F1 0.98 against `pdftotext`) and now rendering (above); qpdf + checks written output on every CI run since `PDFCRAFT_ORACLES_REQUIRED`. Acrobat captures must + stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). +- **Still missing:** Acrobat itself as the reference rather than other open renderers; form + calculation and signature validation; the interop suite that opens every kind of output we write + (encrypted ones above all, after #774) in pdf.js and MuPDF; and 15 of those 250 files could not + be compared at all because the two renderers round the page box to different pixel sizes. +- **Impact:** until it is complete, quality numbers outside rendering and text are judgements. +- **Estimate:** counted in gap 2 (≈ 20–30 h of it; roughly a third done). + **Doc:** [target-app-parity.md](target-app-parity.md). ### 13. Performance budgets and lazy loading of very large files diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 756f7237c..3f56e207a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -335,7 +335,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output"] -notes = "skipped when qpdf is absent" +notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle)" [[feature]] id = "core.deterministic-save" @@ -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" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index def0fe755..7c6ca7aed 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -6,6 +6,8 @@ publish = false license.workspace = true [dependencies] +pdfcraft-oracle = { workspace = true } +pdfcraft-cos = { workspace = true } anyhow = "1" lopdf = "0.45" png = "0.18" diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index fc95a6ffb..1352a64c8 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -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()); diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 4406a957c..6591233e9 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -200,9 +200,10 @@ pub fn check(args: &[String]) -> anyhow::Result<()> { /// median. Plan target (M2): median ≥ 0.97. pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let limit: usize = args.iter().position(|a| a == "--limit").and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(usize::MAX); - if Command::new("pdftotext").arg("-v").output().is_err() { - bail!("pdftotext (poppler) not found; install it to run the text oracle (brew install poppler / apt install poppler-utils)"); - } + let pdftotext = match pdfcraft_oracle::pdftotext() { + Ok(o) => o, + Err(absent) => bail!("{absent}"), + }; run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; let corpus = root().join("corpus/pdfjs/test/pdfs"); let mut files: Vec = @@ -211,9 +212,18 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let cli = release_cli(); let mut scores = Vec::new(); let mut worst: Vec<(f64, String)> = Vec::new(); + let mut not_permitted = 0usize; for f in files.iter().take(limit) { + // A document may forbid copying its text, and we honour that: `pdfcraft-cli text` refuses + // and says how to override it with the owner password. pdftotext ignores the flag and + // extracts anyway, so scoring us against it there measures obedience, not quality -- + // bug1815476.pdf scored 0.000 for behaving correctly on a page we render perfectly. + if !copying_allowed(f) { + not_permitted = not_permitted.saturating_add(1); + continue; + } let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20); - let theirs = run_capture(Command::new("pdftotext").arg("-q").arg(f).arg("-"), 20); + let theirs = run_capture(pdftotext.command().arg("-q").arg(f).arg("-"), 20); let (Some(ours), Some(theirs)) = (ours, theirs) else { continue }; let (a, b) = (words(&ours), words(&theirs)); if b.len() < 20 { @@ -231,6 +241,9 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let median = scores[scores.len() / 2]; let mean = scores.iter().sum::() / scores.len() as f64; println!("text oracle: {} files with text; median F1 {:.3}, mean {:.3}, p10 {:.3}", scores.len(), median, mean, scores[scores.len() / 10]); + if not_permitted > 0 { + println!(" {not_permitted} skipped: the document forbids copying its text, which we honour and pdftotext does not"); + } println!("lowest:"); for (s, n) in worst.iter().take(12) { println!(" {s:.3} {n}"); @@ -238,15 +251,153 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { Ok(()) } +/// Compare our rendering against Poppler's, page 1 of every PDF in a directory. +/// +/// This is the measured half of the fidelity gap (`docs/gaps.md` gap 12): until it exists, every +/// claim about rendering quality is a judgement. It reports rather than gates, because the corpus +/// is not fetched on CI and because a first reading of a borrowed renderer is a baseline to +/// improve on, not a bar to clear. +/// +/// The score is the share of pixels with no near match in the other renderer's 3x3 neighbourhood +/// (`pdfcraft_oracle::pixels`), which forgives antialiasing and subpixel placement but not +/// missing or wrong content. **Lower is better; it is a disagreement rate, not a quality score.** +pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { + let limit: usize = flag_num(args, "--limit").unwrap_or(usize::MAX); + let dpi: u32 = flag_num(args, "--dpi").unwrap_or(72); + let tolerance: u8 = flag_num(args, "--tolerance").unwrap_or(8); + // Annotations are shown by default, which is not the obvious choice and was settled by + // measurement rather than argument. Poppler synthesises an appearance for an annotation that + // has none, where we draw only what the document carries (`view.render-annotation-appearances` + // records link borders without `/AP` as not covered), so showing them scores us for a known + // gap: bug1552113.pdf is 84% purely because Poppler paints a 112-unit link border over the + // text that fixture exists to show -- and our rendering, not Poppler's, is the readable one. + // + // Hiding them is worse, though, because it scores Poppler for every appearance we *do* draw. + // Over the same 250 files: shown gives mean 2.16%, p90 5.35%, 25 files within 0.1%; hidden + // gives 2.18%, 5.55% and 22, with annotation-tx3.pdf jumping to 45.64%. The two biases very + // nearly cancel, so the default stays with the fuller picture and this flag exists to tell + // the two apart when a single file's score needs explaining. + let hide_annotations = args.iter().any(|a| a == "--hide-annotations"); + let dir = + args.iter().position(|a| a == "--dir").and_then(|i| args.get(i + 1)).map_or_else(|| root().join("corpus/pdfjs/test/pdfs"), PathBuf::from); + + let pdftoppm = match pdfcraft_oracle::pdftoppm() { + Ok(tool) => tool, + Err(absent) => bail!("{absent}"), + }; + run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; + let cli = release_cli(); + + let mut files: Vec = std::fs::read_dir(&dir) + .with_context(|| format!("{}: no such directory (cargo xtask corpus, or pass --dir)", dir.display()))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))) + .collect(); + files.sort(); + + let out_pam = std::env::temp_dir().join(format!("pdfcraft-render-oracle-{}.pam", std::process::id())); + let (mut scores, mut sizes_differed, mut ours_failed, mut theirs_failed) = (Vec::new(), 0usize, 0usize, 0usize); + let mut worst: Vec<(f64, String)> = Vec::new(); + for file in files.iter().take(limit) { + let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let ours = Command::new(&cli) + .args(["render", "--page", "1", "--dpi"]) + .arg(dpi.to_string()) + .arg("--out") + .arg(&out_pam) + .arg(file) + .status() + .ok() + .filter(std::process::ExitStatus::success) + .and_then(|_| std::fs::read(&out_pam).ok()) + .and_then(|b| pdfcraft_oracle::pixels::decode_pam_gray(&b)); + let mut theirs_cmd = pdftoppm.command(); + theirs_cmd.args(["-singlefile", "-gray", "-f", "1", "-l", "1", "-r"]).arg(dpi.to_string()); + if hide_annotations { + theirs_cmd.arg("-hide-annotations"); + } + theirs_cmd.arg(file); + let theirs = run_capture_bytes(&mut theirs_cmd, 30).and_then(|b| pdfcraft_oracle::pixels::decode_pgm(&b)); + // An oracle that cannot read a file says nothing about our fidelity, and a file we cannot + // read is `xtask check`'s business, not this one's. Count each side and move on. + if ours.is_none() { + ours_failed = ours_failed.saturating_add(1); + } + if theirs.is_none() { + theirs_failed = theirs_failed.saturating_add(1); + } + let (Some((aw, ah, a)), Some((bw, bh, b))) = (ours, theirs) else { continue }; + let (Some(a), Some(b)) = (pixels_of(aw, ah, &a), pixels_of(bw, bh, &b)) else { continue }; + match pdfcraft_oracle::pixels::compare(a, b, tolerance) { + Ok(diff) => { + scores.push(diff.percent()); + worst.push((diff.percent(), name)); + } + // Different rounding of the page box at this scale: not a fidelity signal. + Err(_) => sizes_differed = sizes_differed.saturating_add(1), + } + } + let _ = std::fs::remove_file(&out_pam); + + if scores.is_empty() { + bail!( + "no comparable files in {} ({sizes_differed} size mismatches, {ours_failed} we could not render, {theirs_failed} Poppler could not)", + dir.display() + ); + } + scores.sort_by(f64::total_cmp); + worst.sort_by(|a, b| b.0.total_cmp(&a.0)); + let median = scores.get(scores.len() / 2).copied().unwrap_or(0.0); + let mean = scores.iter().sum::() / scores.len() as f64; + let p90 = scores.get(scores.len().saturating_mul(9) / 10).copied().unwrap_or(0.0); + let clean = scores.iter().filter(|s| **s < 0.1).count(); + println!("render oracle vs Poppler at {dpi} dpi, tolerance {tolerance} (disagreement rate, lower is better)"); + println!(" {} compared: median {median:.2}%, mean {mean:.2}%, p90 {p90:.2}%", scores.len()); + println!(" {clean} within 0.1%; {sizes_differed} size mismatches, {ours_failed} we could not render, {theirs_failed} Poppler could not"); + println!("worst:"); + for (score, name) in worst.iter().take(12) { + println!(" {score:6.2}% {name}"); + } + Ok(()) +} + +/// Whether the document lets its text be copied. +/// +/// `true` when it is unencrypted, when the flag is set, and when the file cannot be opened at all +/// — a file we cannot read is a different problem, and silently dropping it from the comparison +/// would hide it. +fn copying_allowed(path: &Path) -> bool { + let Ok(bytes) = std::fs::read(path) else { return true }; + let Ok(doc) = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)) else { return true }; + doc.permissions().is_none_or(|p| p.copy()) +} + +/// The value after `name` on the command line, parsed, or `None` when it is absent or malformed. +fn flag_num(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i.checked_add(1)?)).and_then(|v| v.parse().ok()) +} + +fn pixels_of(w: usize, h: usize, buf: &[u8]) -> Option> { + pdfcraft_oracle::pixels::Gray::new(w, h, buf) +} + fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option { + // `read_to_string` left the buffer empty when the output was not UTF-8; `from_utf8` keeps + // exactly that behaviour now that the capture itself is byte-oriented. + Some(String::from_utf8(run_capture_bytes(cmd, timeout_s)?).unwrap_or_default()) +} + +/// [`run_capture`] for output that is not text — an oracle's image on stdout, for instance. +fn run_capture_bytes(cmd: &mut Command, timeout_s: u64) -> Option> { let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn().ok()?; let start = std::time::Instant::now(); - let mut out = String::new(); + let mut out = Vec::new(); let mut stdout = child.stdout.take()?; let reader = std::thread::spawn(move || { use std::io::Read; - let mut s = String::new(); - let _ = stdout.read_to_string(&mut s); + let mut s = Vec::new(); + let _ = stdout.read_to_end(&mut s); s }); loop { @@ -261,7 +412,7 @@ fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option { Err(_) => return None, } } - out.push_str(&reader.join().ok()?); + out.extend_from_slice(&reader.join().ok()?); Some(out) } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 1bda1dc4d..a0907cb1a 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -198,11 +198,17 @@ pub fn check(crates: &[Crate]) -> Vec { continue; } if d.workspace { - if matches!(class, Class::Standalone(_)) && !standalone_allowed(&c.name, &d.name) { + let dep_class = classify(&d.name); + // Test support is a dev-dependency only (`Class::Testkit`) and so never reaches + // the product graph a standalone crate publishes. Checking it against the + // standalone allow-list would stop `cos` using the shared oracles in its own + // tests, which is the one thing those crates exist for. + let testkit_dev = dep_class == Some(Class::Testkit) && d.kind == DepKind::Dev; + if matches!(class, Class::Standalone(_)) && !testkit_dev && !standalone_allowed(&c.name, &d.name) { out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); continue; } - match classify(&d.name) { + match dep_class { None => {} Some(Class::Testkit) if d.kind != DepKind::Dev && class != Class::Testkit => { out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() }); @@ -322,6 +328,17 @@ mod tests { assert!(check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Dev, true)])]).is_empty()); let v = check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Normal, true)])]); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); + + // A standalone crate may use test support in its own tests: a dev-dependency is not part + // of the graph the standalone rule protects. `cos` is where the qpdf oracle is needed. + assert!(check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Dev, true)])]).is_empty()); + assert!(check(&[c("pdfcraft-geom", &[("pdfcraft-testkit", Dev, true)])]).is_empty()); + // ...but only as a dev-dependency, and only for test support. Product crates are still + // off-limits to a standalone crate however they are depended on. + let v = check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Normal, true)])]); + assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}"); + let v = check(&[c("pdfcraft-cos", &[("pdfcraft-model", Dev, true)])]); + assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}"); } #[test] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 70741d22b..f06c09944 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -33,6 +33,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ macos_integration::run, ), ("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle), + ( + "render-oracle", + "Compare page rendering with Poppler over corpus/ (pixel disagreement rate; --dir, --dpi, --limit, --tolerance, --hide-annotations)", + gates::render_oracle, + ), ("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run), ("models", "Fetch the OCR models (ATTRIBUTION.toml kind = \"model\") into assets/models/, verified by SHA-256", assets::models), ("demo-pdf", "Build dist/demo/pdfcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run),