mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
Merge main: resolve ATTRIBUTION hashes and add Next/Previous tab
Upstream moved the Hungarian catalog and std14_metrics forward and added the Next tab / Previous tab labels (document-tab cycling); the four new catalogs translate both, and their ATTRIBUTION.toml hashes are recomputed. The manifest check passes with 274 repository assets.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -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. |
|
||||
|
||||
+37
-25
@@ -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"
|
||||
|
||||
Generated
+49
-34
@@ -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",
|
||||
|
||||
+34
-32
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
|
||||
- Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …).
|
||||
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
|
||||
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
|
||||
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
|
||||
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss.
|
||||
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
|
||||
|
||||
## Text search and cache limits
|
||||
|
||||
@@ -858,6 +858,10 @@ impl Automation {
|
||||
}
|
||||
|
||||
fn doc_save(&mut self, a: &Args) -> Result<Value> {
|
||||
self.doc_save_with(a, write_atomic)
|
||||
}
|
||||
|
||||
fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
let target = match a.opt_str("path")? {
|
||||
@@ -875,7 +879,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::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
// `substitute` names the face standing in for a program the document does not embed, and
|
||||
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
|
||||
// `substitute` with a non-null `substitution` means no face was available at all, so
|
||||
// nothing is drawn for that font -- which is the answer to "why is my text missing?".
|
||||
"fonts": i.fonts.iter().map(|f| json!({
|
||||
"name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding,
|
||||
"substitute": f.substitute, "substitution": f.substitution,
|
||||
})).collect::<Vec<_>>(),
|
||||
"warnings": i.warnings,
|
||||
"repairs": d.repair_log(),
|
||||
@@ -2301,13 +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<Item = u64>) -> Result<()> {
|
||||
write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all)
|
||||
}
|
||||
|
||||
fn write_atomic_synced(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
|
||||
) -> Result<()> {
|
||||
use std::io::Write;
|
||||
// A folder can't be replaced, and staging beside it could land outside the root: "." names
|
||||
// the root itself, whose parent isn't ours to write in.
|
||||
@@ -2322,7 +2343,9 @@ fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator<Item
|
||||
// Closed at the end of the block, before the rename.
|
||||
let written = {
|
||||
let mut file = file;
|
||||
file.write_all(bytes)
|
||||
// Surface delayed writeback failures before replacing the destination or marking
|
||||
// a document clean. The handle is closed before rename (required on Windows).
|
||||
file.write_all(bytes).and_then(|()| sync(&file))
|
||||
};
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
@@ -2680,6 +2703,79 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_staging_sync_keeps_the_destination_and_cleans_up() {
|
||||
for exists in [false, true] {
|
||||
let dir = staging_dir(if exists { "sync-existing" } else { "sync-new" });
|
||||
let target = dir.join("out.pdf");
|
||||
if exists {
|
||||
std::fs::write(&target, "OLD").unwrap();
|
||||
}
|
||||
let result = write_atomic_synced(&target, b"NEW", [1], |_| {
|
||||
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW", "sync follows writing");
|
||||
if exists {
|
||||
assert_eq!(read(&target), "OLD", "sync precedes publication");
|
||||
}
|
||||
Err(std::io::Error::other("injected storage sync failure"))
|
||||
});
|
||||
assert!(result.is_err(), "sync failure must refuse publication");
|
||||
assert!(result.unwrap_err().to_string().contains("injected storage sync failure"));
|
||||
if exists {
|
||||
assert_eq!(read(&target), "OLD");
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
} else {
|
||||
assert!(!target.exists());
|
||||
assert!(listing(&dir).is_empty());
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() {
|
||||
let dir = staging_dir("sync-dirty");
|
||||
let target = dir.join("out.pdf");
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = search_document(&mut a, 2);
|
||||
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
|
||||
let old = std::fs::read(&target).unwrap();
|
||||
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
|
||||
let args = json!({"doc": id.0});
|
||||
let before = summary(a.doc(&Args(&args)).unwrap());
|
||||
let result = a.doc_save_with(&Args(&args), |path, bytes| {
|
||||
write_atomic_synced(path, bytes, [1], |_| Err(std::io::Error::other("injected storage sync failure")))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
let doc = a.doc(&Args(&args)).unwrap();
|
||||
assert!(doc.dirty);
|
||||
assert_eq!(summary(doc), before, "failed save must not reset history or saved state");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), old);
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
json(&mut a, "doc_save", args.clone());
|
||||
assert!(!a.doc(&Args(&args)).unwrap().dirty);
|
||||
assert_ne!(std::fs::read(&target).unwrap(), old);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_staging_sync_precedes_publication() {
|
||||
let dir = staging_dir("sync-success");
|
||||
let target = dir.join("out.pdf");
|
||||
std::fs::write(&target, "OLD").unwrap();
|
||||
let mut called = false;
|
||||
write_atomic_synced(&target, b"NEW", [1], |file| {
|
||||
called = true;
|
||||
assert_eq!(read(&target), "OLD");
|
||||
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW");
|
||||
file.sync_all()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(called);
|
||||
assert_eq!(read(&target), "NEW");
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The names in a folder: what a test can see was left behind.
|
||||
fn listing(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,6 +9,7 @@ fn result(auto: &mut Automation, args: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
|
||||
fn pdf() -> Vec<u8> {
|
||||
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
|
||||
}
|
||||
@@ -49,8 +50,9 @@ fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
|
||||
std::fs::write(dir.path().join("input/0.pdf"), b"broken").unwrap();
|
||||
std::fs::write(dir.path().join("input/1.pdf"), pdf()).unwrap();
|
||||
std::fs::write(dir.path().join("input/sub/2.pdf"), pdf()).unwrap();
|
||||
let original = pdf();
|
||||
std::fs::write(dir.path().join("input/1.pdf"), &original).unwrap();
|
||||
std::fs::write(dir.path().join("input/sub/2.pdf"), &original).unwrap();
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","stop_on_error":true}));
|
||||
assert_eq!(report["failed"], 1);
|
||||
@@ -61,7 +63,7 @@ fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() {
|
||||
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"}));
|
||||
assert_eq!(report["completed"], 1);
|
||||
assert!(!dir.path().join("out/sub").exists());
|
||||
assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= pdf().len() as u64);
|
||||
assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= original.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -107,12 +109,13 @@ fn explicitly_selected_subfolder_is_processed_without_recursive() {
|
||||
#[test]
|
||||
fn explicit_file_can_write_a_numbered_copy_beside_its_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
|
||||
let original = pdf();
|
||||
std::fs::write(dir.path().join("page.pdf"), &original).unwrap();
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"."}));
|
||||
assert_eq!(report["completed"], 1, "{report}");
|
||||
assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), pdf());
|
||||
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), pdf());
|
||||
assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), original);
|
||||
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), original);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-39
@@ -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<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (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<u8> {
|
||||
@@ -159,18 +148,14 @@ fn classic_table() -> Vec<u8> {
|
||||
bytes
|
||||
}
|
||||
|
||||
fn qpdf_check(bytes: &[u8]) -> Option<std::process::Output> {
|
||||
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}");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Vec<u8>>) -> 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<Vec<u8>>, path: Option<String>) -> Result<(), EditError> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! The one chain PdfCraft follows when a document does not embed a font program, and a record of
|
||||
//! what it chose and why.
|
||||
//!
|
||||
//! Substitution used to be decided per caller and never written down: `edit` picked a craft face
|
||||
//! by the script of the text it was drawing, `Std14::from_base_font` matched Latin names, and the
|
||||
//! renderer did its own thing. Nothing told the *user* that the glyphs they are looking at are not
|
||||
//! the ones the document asked for, which is the difference between "PdfCraft shows nothing for my
|
||||
//! Chinese PDF" being a mystery and being a sentence.
|
||||
//!
|
||||
//! This module answers the *dictionary* question — "the document does not carry this program; what
|
||||
//! will stand in for it, and why?" — which is what inspection and reporting need. It deliberately
|
||||
//! does not answer the *text* question ("which face has a glyph for こ?"), because that depends on
|
||||
//! the string being drawn and is already handled where the drawing happens. It delegates to the
|
||||
//! existing per-script selectors rather than restating their order, so there is one source of
|
||||
//! truth for each.
|
||||
//!
|
||||
//! The chain is tried in order, and the first step that yields a face wins:
|
||||
//!
|
||||
//! 1. The document embeds the program. Nothing is substituted.
|
||||
//! 2. `/BaseFont` names one of the standard 14 (or a well-known alias such as Arial). Exact
|
||||
//! published metrics apply, so this is as close to right as a substitution gets.
|
||||
//! 3. craft-fonts holds that exact family. Never another family — see [`crate::embedding`].
|
||||
//! 4. The descendant font's `/CIDSystemInfo /Ordering` names a script, and craft-fonts has a face
|
||||
//! for it. Today that means Adobe-Japan1.
|
||||
//! 5. The ordering names a script we have **no** licensed face for (GB1, CNS1, Korea1). There is
|
||||
//! no honest substitute, so the chain reports none: the caller draws nothing and can say why.
|
||||
//! 6. Otherwise, the nearest of the standard 14 by `/FontDescriptor /Flags` — serif, fixed pitch,
|
||||
//! bold and italic. This step always succeeds, so a simple font is never left without a face.
|
||||
|
||||
use crate::{CraftFont, Std14};
|
||||
|
||||
/// What a font dictionary asks for, as far as the dictionary alone can say.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Request<'a> {
|
||||
/// `/BaseFont` with any `ABCDEF+` subset tag already stripped.
|
||||
pub base_font: &'a str,
|
||||
/// Whether the descriptor carries `/FontFile`, `/FontFile2` or `/FontFile3` (or it is Type 3).
|
||||
pub embedded: bool,
|
||||
/// `/FontDescriptor /Flags`, or 0 when there is no descriptor.
|
||||
pub flags: u32,
|
||||
/// The descendant font's `/CIDSystemInfo /Ordering`, for a composite font.
|
||||
pub ordering: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// The face the chain picked.
|
||||
///
|
||||
/// `Debug`, `PartialEq` and `Eq` are written by hand rather than derived, because [`CraftFont`]
|
||||
/// carries a whole font program: deriving would print megabytes and compare font files.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Face {
|
||||
/// The document's own program; nothing was substituted.
|
||||
Embedded,
|
||||
/// One of the standard 14, whose metrics are exact.
|
||||
Standard14(Std14),
|
||||
/// A face from the optional craft-fonts build input.
|
||||
Craft(&'static CraftFont),
|
||||
/// No face at all. The caller must draw nothing rather than draw the wrong glyphs.
|
||||
None,
|
||||
}
|
||||
|
||||
impl PartialEq for Face {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Embedded, Self::Embedded) | (Self::None, Self::None) => true,
|
||||
(Self::Standard14(a), Self::Standard14(b)) => a == b,
|
||||
// craft-fonts entries are unique statics, so identity is the comparison that means
|
||||
// what we want: two styles of one family are different faces and must not be equal.
|
||||
(Self::Craft(a), Self::Craft(b)) => std::ptr::eq(*a, *b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Face {}
|
||||
|
||||
impl std::fmt::Debug for Face {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Embedded => f.write_str("Embedded"),
|
||||
Self::Standard14(s) => write!(f, "Standard14({})", s.base_font()),
|
||||
// The name, never the bytes.
|
||||
Self::Craft(c) => write!(f, "Craft({})", c.name()),
|
||||
Self::None => f.write_str("None"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the chain picked that face. The variants are in the order the chain tries them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Why {
|
||||
/// The document carries the font program.
|
||||
Embedded,
|
||||
/// `/BaseFont` names a standard-14 face or a well-known alias of one.
|
||||
NamedStandard14,
|
||||
/// `/BaseFont` is otherwise unknown, but its name hints at a family ("Serif", "Roman",
|
||||
/// "Mono"). A guess from the name, and reported as one.
|
||||
NameHintedStandard14,
|
||||
/// craft-fonts holds this exact family.
|
||||
ExactCraftFamily,
|
||||
/// A craft-fonts face for the script named by `/CIDSystemInfo /Ordering`.
|
||||
ScriptFace,
|
||||
/// The ordering names a script with no licensed face available.
|
||||
NoFaceForScript,
|
||||
/// The nearest standard-14 face by descriptor flags.
|
||||
StyleMatched,
|
||||
}
|
||||
|
||||
impl Why {
|
||||
/// A stable sentence for a report, written for whoever has to act on it.
|
||||
pub fn note(self) -> &'static str {
|
||||
match self {
|
||||
Self::Embedded => "embedded in the document; not substituted",
|
||||
Self::NamedStandard14 => "names one of the standard 14 fonts, or a well-known alias of one, whose metrics are exact",
|
||||
Self::NameHintedStandard14 => {
|
||||
"not embedded and not a known family, but its name hints at one (Serif, Roman or Mono), so the nearest standard-14 face was chosen from that hint"
|
||||
}
|
||||
Self::ExactCraftFamily => "this exact family is available, so no other family was chosen",
|
||||
Self::ScriptFace => "substituted by a face for the script the font declares",
|
||||
Self::NoFaceForScript => {
|
||||
"no licensed face is available for the script this font declares, so nothing is drawn for it \
|
||||
rather than the wrong glyphs"
|
||||
}
|
||||
Self::StyleMatched => "not embedded and not a known family, so the nearest standard-14 face was chosen by its descriptor flags",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document's own glyphs are being shown. Only [`Why::Embedded`] is faithful.
|
||||
pub fn is_faithful(self) -> bool {
|
||||
matches!(self, Self::Embedded)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the chain chose, and why.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Substitution {
|
||||
pub face: Face,
|
||||
pub why: Why,
|
||||
}
|
||||
|
||||
impl Substitution {
|
||||
/// The chosen face's name, for a report. `None` when nothing was chosen.
|
||||
pub fn face_name(&self) -> Option<String> {
|
||||
match self.face {
|
||||
Face::Embedded => None,
|
||||
Face::Standard14(f) => Some(f.base_font().to_owned()),
|
||||
Face::Craft(f) => Some(f.name()),
|
||||
Face::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CID orderings we recognise, and whether a craft-fonts face may exist for them.
|
||||
///
|
||||
/// `Identity` is not a script: it says the codes are glyph ids, so it tells us nothing about
|
||||
/// which face would suit and the chain falls through to the flags.
|
||||
fn script_for(ordering: &str) -> Option<&'static str> {
|
||||
match ordering {
|
||||
"Japan1" => Some("Jpan"),
|
||||
// Registered orderings we have no openly licensed face for. AGENTS.md 1.1 rules out
|
||||
// Source Han and Noto CJK (Source Han rebranded), which is why these have none.
|
||||
"GB1" | "CNS1" | "Korea1" | "KR" => Some(""),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the chain to `req`. Total: every request gets a [`Substitution`].
|
||||
///
|
||||
/// See the module documentation for the order and the reasoning behind each step.
|
||||
pub fn substitute(req: &Request<'_>) -> Substitution {
|
||||
let at = |face, why| Substitution { face, why };
|
||||
if req.embedded {
|
||||
return at(Face::Embedded, Why::Embedded);
|
||||
}
|
||||
if let Some((f, how)) = Std14::recognise(req.base_font) {
|
||||
return match how {
|
||||
crate::std14::Match::Alias => at(Face::Standard14(f), Why::NamedStandard14),
|
||||
crate::std14::Match::FamilyHint => at(Face::Standard14(f), Why::NameHintedStandard14),
|
||||
};
|
||||
}
|
||||
if let Some(f) = crate::embedding::matching_faces(req.base_font).first() {
|
||||
return at(Face::Craft(f), Why::ExactCraftFamily);
|
||||
}
|
||||
if let Some(script) = req.ordering.and_then(script_for) {
|
||||
// A script we hold a face for; otherwise there is no honest substitute to offer.
|
||||
return match crate::CRAFT_FONTS.iter().find(|f| f.covers(script)) {
|
||||
Some(f) => at(Face::Craft(f), Why::ScriptFace),
|
||||
None => at(Face::None, Why::NoFaceForScript),
|
||||
};
|
||||
}
|
||||
at(Face::Standard14(style_matched(req.flags)), Why::StyleMatched)
|
||||
}
|
||||
|
||||
/// The nearest standard-14 face for `/FontDescriptor /Flags` (ISO 32000-2 table 121): fixed pitch
|
||||
/// is bit 1, serif bit 2, italic bit 7 and ForceBold bit 19.
|
||||
fn style_matched(flags: u32) -> Std14 {
|
||||
let (bold, italic) = (flags & 0x4_0000 != 0, flags & 0x40 != 0);
|
||||
match (flags & 1 != 0, flags & 2 != 0, bold, italic) {
|
||||
(true, _, false, false) => Std14::Courier,
|
||||
(true, _, true, false) => Std14::CourierBold,
|
||||
(true, _, false, true) => Std14::CourierOblique,
|
||||
(true, _, true, true) => Std14::CourierBoldOblique,
|
||||
(false, true, false, false) => Std14::TimesRoman,
|
||||
(false, true, true, false) => Std14::TimesBold,
|
||||
(false, true, false, true) => Std14::TimesItalic,
|
||||
(false, true, true, true) => Std14::TimesBoldItalic,
|
||||
(false, false, false, false) => Std14::Helvetica,
|
||||
(false, false, true, false) => Std14::HelveticaBold,
|
||||
(false, false, false, true) => Std14::HelveticaOblique,
|
||||
(false, false, true, true) => Std14::HelveticaBoldOblique,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn req<'a>(base: &'a str, embedded: bool, flags: u32, ordering: Option<&'a str>) -> Request<'a> {
|
||||
Request { base_font: base, embedded, flags, ordering }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_font_is_never_substituted() {
|
||||
// True even when the name and flags would otherwise pick something: embedding wins first.
|
||||
for (base, ordering) in [("Helvetica", None), ("Whatever", Some("GB1")), ("", None)] {
|
||||
let s = substitute(&req(base, true, 0, ordering));
|
||||
assert_eq!(s, Substitution { face: Face::Embedded, why: Why::Embedded });
|
||||
assert!(s.why.is_faithful());
|
||||
assert_eq!(s.face_name(), None, "nothing stands in, so there is no substitute to name");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standard_14_name_wins_over_the_flags() {
|
||||
// Flags claiming a fixed-pitch serif must not override a name we have exact metrics for.
|
||||
let s = substitute(&req("Times-BoldItalic", false, 0b11, None));
|
||||
assert_eq!(s.face, Face::Standard14(Std14::TimesBoldItalic));
|
||||
assert_eq!(s.why, Why::NamedStandard14);
|
||||
assert_eq!(s.face_name().as_deref(), Some("Times-BoldItalic"));
|
||||
// Aliases and subset tags resolve the same way.
|
||||
for name in ["Arial", "ArialMT", "Arial-Bold", "Symbol", "ZapfDingbats", "CourierNew"] {
|
||||
assert_eq!(substitute(&req(name, false, 0, None)).why, Why::NamedStandard14, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_hint_is_reported_as_a_guess_not_as_a_standard_14_name() {
|
||||
// A house font whose name merely contains "Serif" gets Times, which is a sensible guess --
|
||||
// but calling that "names one of the standard 14" would tell the reader something untrue.
|
||||
let hinted = substitute(&req("SomeHouseSerif", false, 0, None));
|
||||
assert_eq!(hinted.face, Face::Standard14(Std14::TimesRoman));
|
||||
assert_eq!(hinted.why, Why::NameHintedStandard14);
|
||||
assert!(hinted.why.note().contains("hints at one"), "{}", hinted.why.note());
|
||||
|
||||
// Style words still apply to the guess.
|
||||
assert_eq!(substitute(&req("SomeHouseSerif-BoldItalic", false, 0, None)).face, Face::Standard14(Std14::TimesBoldItalic));
|
||||
// "Mono" hints at Courier the same way.
|
||||
assert_eq!(substitute(&req("HouseMono", false, 0, None)).why, Why::NameHintedStandard14);
|
||||
|
||||
// A real alias is still reported as the request it is.
|
||||
for alias in ["Arial", "CourierNew", "Times-Roman", "Symbol"] {
|
||||
assert_eq!(substitute(&req(alias, false, 0, None)).why, Why::NamedStandard14, "{alias}");
|
||||
}
|
||||
// A name with no hint at all falls through to the flags instead.
|
||||
assert_eq!(substitute(&req("HouseGrotesk", false, 0b10, None)).why, Why::StyleMatched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_pick_the_nearest_of_the_fourteen() {
|
||||
// Bit 1 fixed pitch, bit 2 serif, bit 7 italic, bit 19 ForceBold.
|
||||
let cases = [
|
||||
(0, Std14::Helvetica),
|
||||
(0b10, Std14::TimesRoman),
|
||||
(0b1, Std14::Courier),
|
||||
(0x40, Std14::HelveticaOblique),
|
||||
(0x4_0000, Std14::HelveticaBold),
|
||||
(0x4_0040, Std14::HelveticaBoldOblique),
|
||||
(0b10 | 0x40, Std14::TimesItalic),
|
||||
(0b10 | 0x4_0000, Std14::TimesBold),
|
||||
(0b10 | 0x4_0040, Std14::TimesBoldItalic),
|
||||
(0b1 | 0x40, Std14::CourierOblique),
|
||||
(0b1 | 0x4_0000, Std14::CourierBold),
|
||||
(0b1 | 0x4_0040, Std14::CourierBoldOblique),
|
||||
// Fixed pitch and serif together: monospace is the stronger signal for layout.
|
||||
(0b11, Std14::Courier),
|
||||
];
|
||||
for (flags, want) in cases {
|
||||
let s = substitute(&req("SomethingUnknown", false, flags, None));
|
||||
assert_eq!(s.face, Face::Standard14(want), "flags {flags:#x}");
|
||||
assert_eq!(s.why, Why::StyleMatched);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_with_no_licensed_face_reports_none_rather_than_latin() {
|
||||
// The user's Chinese PDF draws nothing. The point of this is that we can now say so,
|
||||
// instead of quietly standing Helvetica in and showing the wrong glyphs.
|
||||
for ordering in ["GB1", "CNS1", "Korea1", "KR"] {
|
||||
let s = substitute(&req("SomeCJKFont", false, 0, Some(ordering)));
|
||||
assert_eq!(s.face, Face::None, "{ordering}");
|
||||
assert_eq!(s.why, Why::NoFaceForScript, "{ordering}");
|
||||
assert_eq!(s.face_name(), None);
|
||||
assert!(!s.why.is_faithful());
|
||||
assert!(s.why.note().contains("no licensed face"), "{ordering}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_ordering_is_not_a_script_and_falls_through_to_the_flags() {
|
||||
// /Ordering (Identity) says the codes are glyph ids. It implies nothing about the face,
|
||||
// so treating it as a script would invent a substitution the dictionary never asked for.
|
||||
let s = substitute(&req("SomeSubsetFont", false, 0b10, Some("Identity")));
|
||||
assert_eq!(s.face, Face::Standard14(Std14::TimesRoman));
|
||||
assert_eq!(s.why, Why::StyleMatched);
|
||||
// An unregistered ordering is treated the same way, not guessed at.
|
||||
assert_eq!(substitute(&req("X", false, 0, Some("MadeUp1"))).why, Why::StyleMatched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_depends_on_craft_fonts_and_says_which_it_got() {
|
||||
// craft-fonts is an optional build input, so both outcomes are correct -- but they must be
|
||||
// distinguishable, and neither may be a silent Latin stand-in.
|
||||
let s = substitute(&req("SomeJapaneseFont", false, 0, Some("Japan1")));
|
||||
if crate::CRAFT_FONTS.iter().any(|f| f.covers("Jpan")) {
|
||||
assert_eq!(s.why, Why::ScriptFace);
|
||||
assert!(matches!(s.face, Face::Craft(_)));
|
||||
assert!(s.face_name().is_some_and(|n| !n.is_empty()));
|
||||
} else {
|
||||
assert_eq!(s.why, Why::NoFaceForScript);
|
||||
assert_eq!(s.face, Face::None);
|
||||
}
|
||||
assert!(!s.why.is_faithful(), "a substitution is never faithful");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_why_has_a_distinct_usable_note() {
|
||||
let all = [
|
||||
Why::Embedded,
|
||||
Why::NamedStandard14,
|
||||
Why::NameHintedStandard14,
|
||||
Why::ExactCraftFamily,
|
||||
Why::ScriptFace,
|
||||
Why::NoFaceForScript,
|
||||
Why::StyleMatched,
|
||||
];
|
||||
let mut notes: Vec<&str> = all.iter().map(|w| w.note()).collect();
|
||||
notes.sort_unstable();
|
||||
let before = notes.len();
|
||||
notes.dedup();
|
||||
assert_eq!(notes.len(), before, "each reason reads differently");
|
||||
assert!(all.iter().all(|w| !w.note().is_empty() && w.note().len() > 20), "notes are sentences, not labels");
|
||||
assert_eq!(all.iter().filter(|w| w.is_faithful()).count(), 1, "only an embedded program is faithful");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_total_and_never_panics() {
|
||||
// Every field is attacker-controlled: a name is any bytes the document chose.
|
||||
let long = "a".repeat(10_000);
|
||||
let names = ["", " ", "+", "ABCDEF+", "\u{0}\u{1}", long.as_str(), "../../etc/passwd", "Ĥéļvéťïçà"];
|
||||
for name in names {
|
||||
for flags in [0, u32::MAX, 0x4_0040, 1] {
|
||||
for ordering in [None, Some(""), Some("Japan1"), Some("\u{0}")] {
|
||||
let s = substitute(&req(name, false, flags, ordering));
|
||||
// Always a decision, and the reason always reads.
|
||||
assert!(!s.why.note().is_empty());
|
||||
assert_eq!(s.face == Face::None, s.why == Why::NoFaceForScript, "only one step declines: {name:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+40
-312
@@ -32,44 +32,60 @@ pub enum Std14 {
|
||||
ZapfDingbats,
|
||||
}
|
||||
|
||||
/// How [`Std14::recognise`] matched a `/BaseFont` name.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Match {
|
||||
/// A standard-14 face or a well-known alias of one (Arial for Helvetica, CourierNew for
|
||||
/// Courier). The document is asking for this face.
|
||||
Alias,
|
||||
/// Only a family hint matched -- "Serif", "Roman" or "Mono" in an otherwise unknown name.
|
||||
/// The face is a guess from the name, not the one the document asked for.
|
||||
FamilyHint,
|
||||
}
|
||||
|
||||
impl Std14 {
|
||||
/// The font a `/BaseFont` name selects, by the spec's names and the common aliases viewers
|
||||
/// accept (`Arial` for `Helvetica`, `TimesNewRoman` for `Times`, `CourierNew` for `Courier`).
|
||||
/// A subset prefix such as `ABCDEF+Helvetica` is ignored.
|
||||
pub fn from_base_font(base: &str) -> Option<Self> {
|
||||
Self::recognise(base).map(|(face, _)| face)
|
||||
}
|
||||
|
||||
/// [`Std14::from_base_font`], also saying *how* the name matched.
|
||||
///
|
||||
/// Reporting needs the difference. "Arial" really is a request for Helvetica, but
|
||||
/// "SomeHouseSerif" is a house font whose name merely contains "Serif"; choosing Times for it
|
||||
/// is a reasonable guess from the name, not the face the document asked for, and a substitution
|
||||
/// report that called it a standard-14 name would be telling the reader something untrue.
|
||||
pub fn recognise(base: &str) -> Option<(Self, Match)> {
|
||||
let base = base.rsplit('+').next().unwrap_or(base);
|
||||
let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect();
|
||||
let has = |w: &str| flat.contains(w);
|
||||
let (bold, italic) = (has("bold"), has("italic") || has("oblique"));
|
||||
// Two bools index a four-element array, so the subscript is provably 0..=3.
|
||||
let styled = |faces: [Self; 4]| faces[usize::from(bold) | usize::from(italic) << 1];
|
||||
let courier = || styled([Self::Courier, Self::CourierBold, Self::CourierOblique, Self::CourierBoldOblique]);
|
||||
let times = || styled([Self::TimesRoman, Self::TimesBold, Self::TimesItalic, Self::TimesBoldItalic]);
|
||||
if has("symbol") {
|
||||
return Some(Self::Symbol);
|
||||
return Some((Self::Symbol, Match::Alias));
|
||||
}
|
||||
if has("zapf") || has("dingbat") {
|
||||
return Some(Self::ZapfDingbats);
|
||||
return Some((Self::ZapfDingbats, Match::Alias));
|
||||
}
|
||||
if has("courier") || has("mono") {
|
||||
return Some(match (bold, italic) {
|
||||
(false, false) => Self::Courier,
|
||||
(true, false) => Self::CourierBold,
|
||||
(false, true) => Self::CourierOblique,
|
||||
(true, true) => Self::CourierBoldOblique,
|
||||
});
|
||||
if has("courier") {
|
||||
return Some((courier(), Match::Alias));
|
||||
}
|
||||
if has("times") || has("serif") || has("roman") {
|
||||
return Some(match (bold, italic) {
|
||||
(false, false) => Self::TimesRoman,
|
||||
(true, false) => Self::TimesBold,
|
||||
(false, true) => Self::TimesItalic,
|
||||
(true, true) => Self::TimesBoldItalic,
|
||||
});
|
||||
if has("mono") {
|
||||
return Some((courier(), Match::FamilyHint));
|
||||
}
|
||||
if has("times") {
|
||||
return Some((times(), Match::Alias));
|
||||
}
|
||||
if has("serif") || has("roman") {
|
||||
return Some((times(), Match::FamilyHint));
|
||||
}
|
||||
if has("helvetica") || has("arial") {
|
||||
return Some(match (bold, italic) {
|
||||
(false, false) => Self::Helvetica,
|
||||
(true, false) => Self::HelveticaBold,
|
||||
(false, true) => Self::HelveticaOblique,
|
||||
(true, true) => Self::HelveticaBoldOblique,
|
||||
});
|
||||
return Some((styled([Self::Helvetica, Self::HelveticaBold, Self::HelveticaOblique, Self::HelveticaBoldOblique]), Match::Alias));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -77,7 +93,7 @@ impl Std14 {
|
||||
/// The advance of one character `code`, in thousandths of an em. Zero where the font's
|
||||
/// encoding leaves the code undefined.
|
||||
pub fn width(self, code: u8) -> f64 {
|
||||
f64::from(WIDTHS[self as usize][code as usize])
|
||||
f64::from(crate::std14_metrics::WIDTHS[self as usize][code as usize])
|
||||
}
|
||||
|
||||
/// The advance of `code` at `size` points.
|
||||
@@ -101,298 +117,10 @@ impl Std14 {
|
||||
|
||||
/// The `/BaseFont` name for this font, as it is written into a PDF.
|
||||
pub fn base_font(self) -> &'static str {
|
||||
BASE_FONTS[self as usize]
|
||||
crate::std14_metrics::BASE_FONTS[self as usize]
|
||||
}
|
||||
}
|
||||
|
||||
static BASE_FONTS: [&str; 14] = [
|
||||
"Helvetica",
|
||||
"Helvetica-Bold",
|
||||
"Helvetica-Oblique",
|
||||
"Helvetica-BoldOblique",
|
||||
"Times-Roman",
|
||||
"Times-Bold",
|
||||
"Times-Italic",
|
||||
"Times-BoldItalic",
|
||||
"Courier",
|
||||
"Courier-Bold",
|
||||
"Courier-Oblique",
|
||||
"Courier-BoldOblique",
|
||||
"Symbol",
|
||||
"ZapfDingbats",
|
||||
];
|
||||
|
||||
/// Advance widths by code, one row per [`Std14`] variant in declaration order.
|
||||
#[rustfmt::skip]
|
||||
static WIDTHS: [[u16; 256]; 14] = [
|
||||
// Helvetica
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
|
||||
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
|
||||
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
|
||||
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
|
||||
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
|
||||
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
|
||||
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
|
||||
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
|
||||
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
|
||||
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
|
||||
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
|
||||
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
|
||||
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
|
||||
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
|
||||
],
|
||||
// HelveticaBold
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
|
||||
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
|
||||
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
|
||||
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
|
||||
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
|
||||
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
|
||||
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
|
||||
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
|
||||
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
|
||||
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
|
||||
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
|
||||
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
|
||||
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
|
||||
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
|
||||
],
|
||||
// HelveticaOblique
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
|
||||
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
|
||||
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
|
||||
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
|
||||
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
|
||||
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
|
||||
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
|
||||
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
|
||||
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
|
||||
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
|
||||
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
|
||||
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
|
||||
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
|
||||
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
|
||||
],
|
||||
// HelveticaBoldOblique
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
|
||||
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
|
||||
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
|
||||
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
|
||||
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
|
||||
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
|
||||
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
|
||||
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
|
||||
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
|
||||
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
|
||||
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
|
||||
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
|
||||
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
|
||||
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
|
||||
],
|
||||
// TimesRoman
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278,
|
||||
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444,
|
||||
921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722,
|
||||
556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500,
|
||||
333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500,
|
||||
500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0,
|
||||
500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0,
|
||||
0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722,
|
||||
250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333,
|
||||
400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444,
|
||||
722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
|
||||
722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500,
|
||||
444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
|
||||
500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500,
|
||||
],
|
||||
// TimesBold
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278,
|
||||
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
|
||||
930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778,
|
||||
611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500,
|
||||
333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500,
|
||||
556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0,
|
||||
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0,
|
||||
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722,
|
||||
250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333,
|
||||
400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500,
|
||||
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389,
|
||||
722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556,
|
||||
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
|
||||
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500,
|
||||
],
|
||||
// TimesItalic
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278,
|
||||
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500,
|
||||
920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722,
|
||||
611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500,
|
||||
333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500,
|
||||
500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0,
|
||||
500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0,
|
||||
0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556,
|
||||
250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333,
|
||||
400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500,
|
||||
611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
|
||||
722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500,
|
||||
500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
|
||||
500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444,
|
||||
],
|
||||
// TimesBoldItalic
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278,
|
||||
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
|
||||
832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722,
|
||||
611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500,
|
||||
333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500,
|
||||
500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0,
|
||||
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0,
|
||||
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611,
|
||||
250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333,
|
||||
400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500,
|
||||
667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389,
|
||||
722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500,
|
||||
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
|
||||
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444,
|
||||
],
|
||||
// Courier
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
|
||||
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
|
||||
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
],
|
||||
// CourierBold
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
|
||||
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
|
||||
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
],
|
||||
// CourierOblique
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
|
||||
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
|
||||
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
],
|
||||
// CourierBoldOblique
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
|
||||
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
|
||||
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
||||
],
|
||||
// Symbol
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278,
|
||||
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444,
|
||||
549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722,
|
||||
768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500,
|
||||
500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549,
|
||||
549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603,
|
||||
400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658,
|
||||
823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713,
|
||||
768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603,
|
||||
494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494,
|
||||
0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0,
|
||||
],
|
||||
// ZapfDingbats
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933,
|
||||
911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537,
|
||||
577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831,
|
||||
923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826,
|
||||
815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762,
|
||||
762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0,
|
||||
390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788,
|
||||
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
|
||||
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
|
||||
788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834,
|
||||
873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874,
|
||||
0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0,
|
||||
],
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
@@ -15,3 +15,6 @@ pdfcraft-content = { workspace = true }
|
||||
pdfcraft-annot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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]
|
||||
@@ -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, Absent>`. `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.
|
||||
@@ -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<Oracle, Absent> {
|
||||
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<Oracle, Absent> {
|
||||
find("qpdf", "--version")
|
||||
}
|
||||
|
||||
/// Poppler's `pdftoppm`: renders a page to pixels, for comparing against our own raster output.
|
||||
pub fn pdftoppm() -> Result<Oracle, Absent> {
|
||||
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<Oracle, Absent> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
(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<u8> {
|
||||
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<Diff, SizeMismatch> {
|
||||
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<u8>)> {
|
||||
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::<usize>().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<u8>)> {
|
||||
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<usize> { header.lines().find_map(|l| l.strip_prefix(name)?.trim().parse::<usize>().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<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gray(w: usize, h: usize, fill: u8) -> Vec<u8> {
|
||||
vec![fill; w * h]
|
||||
}
|
||||
|
||||
fn pam(w: usize, h: usize, px: &[u8]) -> Vec<u8> {
|
||||
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<u8>> = 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());
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,5 @@ pdfcraft-cos = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-oracle = { workspace = true }
|
||||
hayro-syntax = { workspace = true }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,3 +19,4 @@ pdfcraft-annot = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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<u8> {
|
||||
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<Vec<u8>>) -> 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]], "");
|
||||
|
||||
+143
-11
@@ -365,6 +365,13 @@ pub struct FontInfo {
|
||||
pub embedded: bool,
|
||||
pub subset: bool,
|
||||
pub encoding: Option<String>,
|
||||
/// The face standing in for a program the document does not embed. `None` when the font is
|
||||
/// embedded (nothing stands in) or when no face was available at all -- `substitution` then
|
||||
/// says which of the two it is.
|
||||
pub substitute: Option<String>,
|
||||
/// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when
|
||||
/// the document's own glyphs are being shown.
|
||||
pub substitution: Option<String>,
|
||||
}
|
||||
|
||||
/// Check that `bytes` still open as the same document: the parser reads them and the page
|
||||
@@ -1290,20 +1297,38 @@ impl<'a> Inspector<'a> {
|
||||
_ => None,
|
||||
};
|
||||
// Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font).
|
||||
let descriptor_of = |d: &Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)).cloned();
|
||||
let desc = descriptor_of(f).or_else(|| {
|
||||
f.get(b"DescendantFonts")
|
||||
.ok()
|
||||
.and_then(|o| self.resolve(o).as_array().ok())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|o| self.dict(o))
|
||||
.and_then(descriptor_of)
|
||||
});
|
||||
let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o));
|
||||
let descendant =
|
||||
f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o));
|
||||
let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of));
|
||||
let embedded =
|
||||
kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok());
|
||||
// What the substitution chain needs beyond the name: the descriptor flags, and for
|
||||
// a composite font the script its descendant declares (ISO 32000-2 9.7.3).
|
||||
let flags = desc
|
||||
.and_then(|d| d.get(b"Flags").ok())
|
||||
.and_then(|o| self.resolve(o).as_i64().ok())
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.unwrap_or(0);
|
||||
let ordering =
|
||||
descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering"));
|
||||
let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
|
||||
let name = if subset { base[7..].to_string() } else { base };
|
||||
let info = FontInfo { name, kind, embedded, subset, encoding };
|
||||
let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request {
|
||||
base_font: &name,
|
||||
embedded,
|
||||
flags,
|
||||
ordering: ordering.as_deref(),
|
||||
});
|
||||
let info = FontInfo {
|
||||
name,
|
||||
kind,
|
||||
embedded,
|
||||
subset,
|
||||
encoding,
|
||||
substitute: chosen.face_name(),
|
||||
substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()),
|
||||
};
|
||||
if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) {
|
||||
out.push(info);
|
||||
}
|
||||
@@ -1981,7 +2006,20 @@ trailer << /Root 1 0 R >>
|
||||
let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" };
|
||||
assert_eq!(data, expected, "{}", a.name);
|
||||
}
|
||||
assert_eq!(info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None }]);
|
||||
// A non-embedded /Helvetica is reported as standing in for itself: the standard-14
|
||||
// metrics are exact, so the substitution is named rather than left implicit.
|
||||
assert_eq!(
|
||||
info.fonts,
|
||||
vec![FontInfo {
|
||||
name: "Helvetica".into(),
|
||||
kind: "Type1".into(),
|
||||
embedded: false,
|
||||
subset: true,
|
||||
encoding: None,
|
||||
substitute: Some("Helvetica".into()),
|
||||
substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
const LAYERS: &[u8] = b"%PDF-1.7
|
||||
@@ -2520,3 +2558,97 @@ mod version_tests {
|
||||
assert_eq!(super::version_label("Unknown"), "Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod substitution_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{FontInfo, inspect};
|
||||
|
||||
/// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a
|
||||
/// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased
|
||||
/// standard-14 name.
|
||||
const NOT_EMBEDDED: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj
|
||||
4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj
|
||||
5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj
|
||||
6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj
|
||||
7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj
|
||||
8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj
|
||||
9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj
|
||||
10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj
|
||||
11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj
|
||||
12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj
|
||||
trailer << /Size 13 /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo {
|
||||
fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_info_says_which_face_stands_in_and_why() {
|
||||
let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens");
|
||||
|
||||
// The one that matters most to a user: a Chinese PDF that draws nothing. Before this,
|
||||
// doc_info said only `embedded: false` and left them to guess.
|
||||
let cjk = font(&info.fonts, "STSong-Light");
|
||||
assert!(!cjk.embedded);
|
||||
assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in");
|
||||
let why = cjk.substitution.as_deref().unwrap_or_default();
|
||||
assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}");
|
||||
|
||||
// An aliased standard-14 name resolves to the real face, whose metrics are exact.
|
||||
let arial = font(&info.fonts, "ArialMT");
|
||||
assert_eq!(arial.substitute.as_deref(), Some("Helvetica"));
|
||||
assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14"));
|
||||
|
||||
// A house font whose name merely contains "Serif" gets Times -- reported as the guess it
|
||||
// is, not as a standard-14 name, which would be untrue of a font nobody has heard of.
|
||||
let serif = font(&info.fonts, "SomeHouseSerif");
|
||||
assert_eq!(serif.substitute.as_deref(), Some("Times-Roman"));
|
||||
assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}");
|
||||
|
||||
// No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic.
|
||||
let grotesk = font(&info.fonts, "HouseGrotesk");
|
||||
assert_eq!(grotesk.substitute.as_deref(), Some("Courier"));
|
||||
assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}");
|
||||
|
||||
// Japanese depends on the optional craft-fonts build input; either way it is reported,
|
||||
// and never as a Latin face.
|
||||
let jpan = font(&info.fonts, "KozMinPro-Regular");
|
||||
assert!(jpan.substitution.is_some(), "a substitution is always explained");
|
||||
match &jpan.substitute {
|
||||
Some(face) => {
|
||||
assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}")
|
||||
}
|
||||
None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")),
|
||||
}
|
||||
|
||||
// Nothing claims to be embedded, so every font here carries a reason.
|
||||
assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts);
|
||||
}
|
||||
|
||||
/// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain.
|
||||
const EMBEDDED: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj
|
||||
4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj
|
||||
5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj
|
||||
6 0 obj << /Length 4 >> stream
|
||||
test
|
||||
endstream endobj
|
||||
trailer << /Size 7 /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn an_embedded_font_reports_no_substitution_at_all() {
|
||||
let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens");
|
||||
let f = font(&info.fonts, "SomeHouseSans");
|
||||
assert!(f.embedded && f.subset, "{f:?}");
|
||||
assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::path::PathBuf> {
|
||||
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<u8>)> {
|
||||
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::path::PathBuf> = 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<u8>), Vec<String>> = 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<String>)> = groups.values().map(|names| (names.len(), names)).collect();
|
||||
sized.sort_by_key(|(n, _)| std::cmp::Reverse(*n));
|
||||
let report: Vec<String> = 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 ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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<Vec<u8>>,
|
||||
trailer: Vec<String>,
|
||||
}
|
||||
|
||||
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<I: IntoIterator<Item = T>, 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<u8> {
|
||||
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::<String>();
|
||||
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<u8> {
|
||||
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<u8>], trailer_extra: &str) -> Vec<u8> {
|
||||
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<u8>> {
|
||||
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<u8>> = 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}");
|
||||
}
|
||||
}
|
||||
@@ -98,15 +98,19 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) {
|
||||
scroll_to = Some((offset - (TAB_MIN_WIDTH + gap)).max(0.0));
|
||||
}
|
||||
let state = (app.active, app.views.len(), app.combine_showing());
|
||||
// Keyed by document, not index: a tab dragged past the active one moves it without
|
||||
// the strip jumping to it.
|
||||
let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing());
|
||||
let changed = ui.data_mut(|data| {
|
||||
let id = ui.id().with("active_tab");
|
||||
let changed = data.get_temp::<(Option<usize>, usize, bool)>(id) != Some(state);
|
||||
let changed = data.get_temp::<(Option<pdfcraft_engine::DocId>, usize, bool)>(id) != Some(state);
|
||||
data.insert_temp(id, state);
|
||||
changed
|
||||
});
|
||||
let mut action = None;
|
||||
let mut close = None;
|
||||
// The pointer's x while a tab is dragged, for scrolling at the strip's edges.
|
||||
let mut drag_x = None;
|
||||
ui.scope(|ui| {
|
||||
ui.style_mut().always_scroll_the_only_direction = true;
|
||||
let mut area = egui::ScrollArea::horizontal().id_salt("document_tabs").max_width(tabs_width).auto_shrink([true, true]);
|
||||
@@ -115,6 +119,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let out = area.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
// Each document tab's index and rect, and the one being dragged
|
||||
// (with whether it was just released).
|
||||
let mut slots = Vec::with_capacity(app.views.len());
|
||||
let mut dragged = None;
|
||||
for i in 0..app.views.len() {
|
||||
let Some(doc) = app.session.get(app.views[i].id) else { continue };
|
||||
let (name, dirty) = (doc.display_name(), doc.dirty);
|
||||
@@ -133,7 +141,12 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
i,
|
||||
cap,
|
||||
tab_h,
|
||||
true,
|
||||
);
|
||||
slots.push((i, response.rect));
|
||||
if response.dragged() || response.drag_stopped() {
|
||||
dragged = Some((i, response.drag_stopped()));
|
||||
}
|
||||
if changed && app.active == Some(i) && !app.combine_showing() {
|
||||
response.scroll_to_me(Some(Align::Center));
|
||||
}
|
||||
@@ -158,6 +171,25 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
app.active = Some(i);
|
||||
}
|
||||
}
|
||||
// Dragging a tab moves it (#1537): a line marks the gap it will land
|
||||
// in, and releasing the button drops it there.
|
||||
if let Some((from, released)) = dragged
|
||||
&& let Some(x) = ui.ctx().pointer_latest_pos().map(|p| p.x)
|
||||
{
|
||||
let to = drop_index(&slots, from, x);
|
||||
if released {
|
||||
if close.is_none() && action.is_none() {
|
||||
app.move_tab(from, to);
|
||||
}
|
||||
} else {
|
||||
drag_x = Some(x);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) {
|
||||
let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range());
|
||||
ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = close
|
||||
&& let Some(view) = app.views.get(i)
|
||||
{
|
||||
@@ -182,6 +214,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
usize::MAX,
|
||||
cap,
|
||||
tab_h,
|
||||
false,
|
||||
);
|
||||
if changed && app.combine_showing() {
|
||||
response.scroll_to_me(Some(Align::Center));
|
||||
@@ -197,6 +230,22 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
let max = (out.content_size.x - out.inner_rect.width()).max(0.0);
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max)));
|
||||
// A tab dragged to either edge of a strip that scrolls brings the hidden tabs in.
|
||||
if let Some(x) = drag_x
|
||||
&& max > 0.0
|
||||
{
|
||||
let step = if x < out.inner_rect.left() + TAB_DRAG_EDGE {
|
||||
-TAB_DRAG_STEP
|
||||
} else if x > out.inner_rect.right() - TAB_DRAG_EDGE {
|
||||
TAB_DRAG_STEP
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if step != 0.0 {
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max)));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
});
|
||||
if overflow && arrow(ui, "chevron-right", offset < max_offset - 0.5, tl!("Scroll tabs right")) {
|
||||
scroll_to = Some((offset + TAB_MIN_WIDTH + gap).min(max_offset));
|
||||
@@ -289,6 +338,29 @@ fn tabs_overflow(natural: &[f32], budget: f32, gap: f32, min: f32) -> bool {
|
||||
narrowest + gap * natural.len().saturating_sub(1) as f32 > budget
|
||||
}
|
||||
|
||||
/// How close to either end of a scrolling strip a dragged tab scrolls it, and how far per frame.
|
||||
const TAB_DRAG_EDGE: f32 = 24.0;
|
||||
const TAB_DRAG_STEP: f32 = 8.0;
|
||||
|
||||
/// Where the tab at `from` lands when dropped with the pointer at `x`: past every other tab
|
||||
/// whose centre is left of the pointer. `slots` are the tabs' indices and rects, in order.
|
||||
fn drop_index(slots: &[(usize, Rect)], from: usize, x: f32) -> usize {
|
||||
slots.iter().filter(|(i, r)| *i != from && r.center().x < x).count()
|
||||
}
|
||||
|
||||
/// The x of the line marking where the tab at `from` drops at `to`: in the gap before the other
|
||||
/// tab it lands in front of, or after the last. `None` when it would stay where it is.
|
||||
fn drop_marker(slots: &[(usize, Rect)], from: usize, to: usize, gap: f32) -> Option<f32> {
|
||||
if slots.iter().position(|(i, _)| *i == from) == Some(to) {
|
||||
return None;
|
||||
}
|
||||
let mut others = slots.iter().filter(|(i, _)| *i != from).map(|(_, r)| r);
|
||||
match to.checked_sub(1) {
|
||||
None => others.next().map(|r| r.left() - gap / 2.0),
|
||||
Some(before) => others.nth(before).map(|r| r.right() + gap / 2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A scroll arrow beside the tabs; dimmed (and inert) at its end. True when clicked.
|
||||
fn arrow(ui: &mut egui::Ui, icon: &str, enabled: bool, tip: &str) -> bool {
|
||||
ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, TAB_ARROW, false, tip)).inner.clicked()
|
||||
@@ -343,6 +415,7 @@ fn tab(
|
||||
index: usize,
|
||||
cap: Option<f32>,
|
||||
height: f32,
|
||||
draggable: bool,
|
||||
) -> egui::Response {
|
||||
let font = theme::regular(13.0);
|
||||
let mut label = tab_name(name);
|
||||
@@ -361,7 +434,8 @@ fn tab(
|
||||
let height = ui.available_height().max(height);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
|
||||
let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() };
|
||||
let resp = craft_ui::tabs::Tab::new(id, &a11y, active).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
|
||||
let sense = if draggable { Sense::click_and_drag() } else { Sense::click() };
|
||||
let resp = craft_ui::tabs::Tab::new(id, &a11y, active).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
|
||||
let press = theme::Press::track(ui, resp);
|
||||
let bg = if active {
|
||||
press.fill(t.chrome, t.chrome.lerp_to_gamma(t.hover, 0.65), t.hover)
|
||||
@@ -750,6 +824,41 @@ mod tab_widths {
|
||||
assert_eq!(tab_cap(&[250.0; 3], 0.0, 4.0, 110.0), Some(110.0));
|
||||
}
|
||||
|
||||
/// Three 100-wide tabs, 4 apart, starting at x = 0.
|
||||
fn three_slots() -> Vec<(usize, egui::Rect)> {
|
||||
(0..3).map(|i| (i, egui::Rect::from_min_size(egui::pos2(i as f32 * 104.0, 0.0), egui::vec2(100.0, 30.0)))).collect()
|
||||
}
|
||||
|
||||
/// A dragged tab lands past every other tab whose centre the pointer has crossed.
|
||||
#[test]
|
||||
fn a_dragged_tab_drops_past_the_centres_it_crossed() {
|
||||
use super::drop_index;
|
||||
let slots = three_slots();
|
||||
// Centres at 50, 154 and 258.
|
||||
assert_eq!(drop_index(&slots, 0, 10.0), 0, "still over itself");
|
||||
assert_eq!(drop_index(&slots, 0, 160.0), 1, "past the middle tab's centre");
|
||||
assert_eq!(drop_index(&slots, 0, 900.0), 2, "past the end");
|
||||
assert_eq!(drop_index(&slots, 2, -50.0), 0, "before the start");
|
||||
assert_eq!(drop_index(&slots, 2, 100.0), 1, "between the first and middle centres");
|
||||
assert_eq!(drop_index(&[], 0, 100.0), 0);
|
||||
}
|
||||
|
||||
/// The drop line sits in the gap the tab will land in, and not at all where it already is.
|
||||
#[test]
|
||||
fn the_drop_line_marks_the_gap_and_hides_when_nothing_moves() {
|
||||
use super::drop_marker;
|
||||
let slots = three_slots();
|
||||
assert_eq!(drop_marker(&slots, 0, 0, 4.0), None);
|
||||
assert_eq!(drop_marker(&slots, 1, 1, 4.0), None);
|
||||
// The first tab dropped at the end: after the last tab.
|
||||
assert_eq!(drop_marker(&slots, 0, 2, 4.0), Some(310.0));
|
||||
// The first tab dropped between the middle and the last: in the gap between them.
|
||||
assert_eq!(drop_marker(&slots, 0, 1, 4.0), Some(206.0));
|
||||
// The last tab dropped at the start: before the first tab.
|
||||
assert_eq!(drop_marker(&slots, 2, 0, 4.0), Some(-2.0));
|
||||
assert_eq!(drop_marker(&[], 0, 1, 4.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mac_title_bar_centres_and_clears_the_traffic_lights() {
|
||||
// The lights are centred in the bar, and content keeps the same gap after them (#789).
|
||||
|
||||
@@ -260,6 +260,8 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
|
||||
"view.next_tab" => self.step_tab(true),
|
||||
"view.previous_tab" => self.step_tab(false),
|
||||
"view.theme" => {
|
||||
let next = if self.theme == ThemeKind::Light { ThemePreference::Dark } else { ThemePreference::Light };
|
||||
self.set_theme_preference(next);
|
||||
@@ -636,6 +638,20 @@ impl PdfCraftApp {
|
||||
true
|
||||
}
|
||||
|
||||
/// Shows the next tab (or the previous one) in the strip's order, wrapping around at either
|
||||
/// end (#1542). The Combine files tab, when open, comes after the documents. From Home the
|
||||
/// next tab is the first and the previous one the last.
|
||||
fn step_tab(&mut self, forward: bool) {
|
||||
let count = self.views.len() + usize::from(self.combine_tab.open);
|
||||
let current = if self.combine_showing() { Some(self.views.len()) } else { self.active };
|
||||
let Some(next) = tab_after(current, count, forward) else { return };
|
||||
if next < self.views.len() {
|
||||
self.active = Some(next);
|
||||
} else {
|
||||
self.open_combine_tab();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the registered keyboard shortcuts (more specific combinations first).
|
||||
pub(crate) fn registry_shortcuts(&mut self, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
@@ -685,6 +701,24 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
|
||||
/// no tab, forward is the first and back the last. `None` when there are no tabs.
|
||||
fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usize> {
|
||||
let last = count.checked_sub(1)?;
|
||||
Some(match (current.filter(|&i| i <= last), forward) {
|
||||
(None, true) => 0,
|
||||
(None, false) => last,
|
||||
(Some(i), true) => {
|
||||
if i == last {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
(Some(i), false) => i.checked_sub(1).unwrap_or(last),
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
|
||||
pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) {
|
||||
for spec in commands::menu(menu) {
|
||||
@@ -761,4 +795,20 @@ mod tests {
|
||||
assert_eq!(command_modifier_label(&ctx), "⌘");
|
||||
crate::i18n::set_current(previous);
|
||||
}
|
||||
|
||||
/// Next and previous tab step one place and wrap around at either end; from no tab they go
|
||||
/// to the first or the last (#1542).
|
||||
#[test]
|
||||
fn tab_steps_wrap_around_and_start_from_either_end() {
|
||||
assert_eq!(tab_after(Some(0), 3, true), Some(1));
|
||||
assert_eq!(tab_after(Some(2), 3, true), Some(0), "the last wraps to the first");
|
||||
assert_eq!(tab_after(Some(1), 3, false), Some(0));
|
||||
assert_eq!(tab_after(Some(0), 3, false), Some(2), "the first wraps to the last");
|
||||
assert_eq!(tab_after(None, 3, true), Some(0));
|
||||
assert_eq!(tab_after(None, 3, false), Some(2));
|
||||
assert_eq!(tab_after(Some(0), 1, true), Some(0), "a single tab stays");
|
||||
assert_eq!(tab_after(Some(7), 3, true), Some(0), "a stale index starts over");
|
||||
assert_eq!(tab_after(None, 0, true), None);
|
||||
assert_eq!(tab_after(Some(0), 0, false), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -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<Stamp>,
|
||||
/// 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<Vec<u8>>,
|
||||
}
|
||||
@@ -81,6 +87,7 @@ pub struct Changed {
|
||||
struct Entry {
|
||||
doc: DocId,
|
||||
path: String,
|
||||
epoch: u64,
|
||||
known: Option<Stamp>,
|
||||
/// A changed version seen once, waiting to be seen unchanged again.
|
||||
pending: Option<Stamp>,
|
||||
@@ -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<DocId, Stamp>,
|
||||
/// 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<DocId, u64>,
|
||||
/// The notice bar's Reload was pressed for this document.
|
||||
pub reload_request: Option<DocId>,
|
||||
}
|
||||
|
||||
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<Item = (DocId, &str, Option<Stamp>)> {
|
||||
fn watched(&self) -> impl Iterator<Item = (DocId, &str, Option<Stamp>, 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<Watched> = self.watched().map(|(doc, path, known)| Watched { doc, path: path.to_owned(), known }).collect();
|
||||
let list: Vec<Watched> = self.watched().map(|(doc, path, known, epoch)| Watched { doc, path: path.to_owned(), known, epoch }).collect();
|
||||
let open: Vec<DocId> = 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<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (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<Changed> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
Column select تحديد عمود
|
||||
Full screen mode وضع ملء الشاشة
|
||||
Read mode وضع القراءة
|
||||
Next tab علامة التبويب التالية
|
||||
Previous tab علامة التبويب السابقة
|
||||
Switch light / dark theme التبديل بين السمة الفاتحة والداكنة
|
||||
Comments panel لوحة التعليقات
|
||||
Form fields panel لوحة حقول النموذج
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -116,6 +116,8 @@
|
||||
Column select Избор на колона
|
||||
Full screen mode Цял екран
|
||||
Read mode Режим на четене
|
||||
Next tab Следващ раздел
|
||||
Previous tab Предишен раздел
|
||||
Switch light / dark theme Превключи светла / тъмна тема
|
||||
Comments panel Панел с коментари
|
||||
Form fields panel Панел с полета на формуляра
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -93,6 +93,8 @@
|
||||
Marquee zoom Zvětšit vybranou oblast
|
||||
Full screen mode Režim celé obrazovky
|
||||
Read mode Režim čtení
|
||||
Next tab Další karta
|
||||
Previous tab Předchozí karta
|
||||
Switch light / dark theme Přepnout světlý / tmavý motiv
|
||||
Comments panel Panel komentářů
|
||||
Form fields panel Panel formulářových polí
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -51,6 +51,8 @@
|
||||
Column select Spaltenauswahl
|
||||
Full screen mode Vollbildmodus
|
||||
Read mode Lesemodus
|
||||
Next tab Nächster Tab
|
||||
Previous tab Vorheriger Tab
|
||||
Switch light / dark theme Helles / dunkles Design umschalten
|
||||
Comments panel Kommentarbereich
|
||||
Form fields panel Formularfeldbereich
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -65,6 +65,8 @@
|
||||
Column select Επιλογή στήλης
|
||||
Full screen mode Λειτουργία πλήρους οθόνης
|
||||
Read mode Λειτουργία ανάγνωσης
|
||||
Next tab Επόμενη καρτέλα
|
||||
Previous tab Προηγούμενη καρτέλα
|
||||
Switch light / dark theme Εναλλαγή φωτεινού / σκοτεινού θέματος
|
||||
Comments panel Πίνακας σχολίων
|
||||
Form fields panel Πίνακας πεδίων φόρμας
|
||||
|
||||
|
@@ -74,6 +74,8 @@
|
||||
Column select Selección de columna
|
||||
Full screen mode Modo de pantalla completa
|
||||
Read mode Modo de lectura
|
||||
Next tab Pestaña siguiente
|
||||
Previous tab Pestaña anterior
|
||||
Switch light / dark theme Cambiar entre tema claro y oscuro
|
||||
Comments panel Panel de comentarios
|
||||
Form fields panel Panel de campos de formulario
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -51,6 +51,8 @@
|
||||
Column select Sélection de colonne
|
||||
Full screen mode Mode plein écran
|
||||
Read mode Mode lecture
|
||||
Next tab Onglet suivant
|
||||
Previous tab Onglet précédent
|
||||
Switch light / dark theme Basculer entre les thèmes clair et sombre
|
||||
Comments panel Panneau des commentaires
|
||||
Form fields panel Panneau des champs de formulaire
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -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 जिसने जारी किया
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 852 and column 12.
|
@@ -51,6 +51,8 @@
|
||||
Column select Oszlopkijelölés
|
||||
Full screen mode Teljes képernyő
|
||||
Read mode Olvasási mód
|
||||
Next tab Következő lap
|
||||
Previous tab Előző lap
|
||||
Switch light / dark theme Világos / sötét téma váltása
|
||||
Comments panel Megjegyzések panel
|
||||
Form fields panel Űrlapmezők panel
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 481 and column 12.
|
@@ -77,6 +77,8 @@
|
||||
Column select Selezione colonna
|
||||
Full screen mode Modalità schermo intero
|
||||
Read mode Modalità lettura
|
||||
Next tab Scheda successiva
|
||||
Previous tab Scheda precedente
|
||||
Switch light / dark theme Alterna tema chiaro/scuro
|
||||
Comments panel Pannello commenti
|
||||
Form fields panel Pannello campi modulo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -80,6 +80,8 @@
|
||||
Column select 列の選択
|
||||
Full screen mode 全画面表示
|
||||
Read mode 閲覧モード
|
||||
Next tab 次のタブ
|
||||
Previous tab 前のタブ
|
||||
Switch light / dark theme 明るい/暗いテーマを切り替え
|
||||
Comments panel コメントパネル
|
||||
Form fields panel フォームフィールドパネル
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Форма өрістері панелі
|
||||
|
||||
|
@@ -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 발급자
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 852 and column 12.
|
@@ -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ą
|
||||
|
||||
|
@@ -76,6 +76,8 @@
|
||||
Take a snapshot Zrób migawkę
|
||||
Full screen mode Tryb pełnoekranowy
|
||||
Read mode Tryb czytania
|
||||
Next tab Następna karta
|
||||
Previous tab Poprzednia karta
|
||||
Switch light / dark theme Przełącz jasny / ciemny motyw
|
||||
Comments panel Panel komentarzy
|
||||
Form fields panel Panel pól formularza
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -58,6 +58,8 @@
|
||||
Column select Seleção de coluna
|
||||
Full screen mode Modo de tela cheia
|
||||
Read mode Modo de leitura
|
||||
Next tab Próxima aba
|
||||
Previous tab Aba anterior
|
||||
Switch light / dark theme Alternar tema claro / escuro
|
||||
Comments panel Painel de comentários
|
||||
Form fields panel Painel de campos de formulário
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -78,6 +78,8 @@
|
||||
Column select Выделение столбца
|
||||
Full screen mode Полноэкранный режим
|
||||
Read mode Режим чтения
|
||||
Next tab Следующая вкладка
|
||||
Previous tab Предыдущая вкладка
|
||||
Switch light / dark theme Переключить светлую / тёмную тему
|
||||
Comments panel Панель комментариев
|
||||
Form fields panel Панель полей формы
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1393,6 +1393,8 @@
|
||||
Read Branje
|
||||
Read Only Samo za branje
|
||||
Read mode Način branja
|
||||
Next tab Naslednji zavihek
|
||||
Previous tab Prejšnji zavihek
|
||||
Ready Omogočeno
|
||||
Real name Pravo ime
|
||||
Reason Razlog
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -57,6 +57,8 @@ comment menu Edit సవరించు
|
||||
Column select నిలువు వరుస ఎంపిక
|
||||
Full screen mode పూర్తి స్క్రీన్ మోడ్
|
||||
Read mode చదివే మోడ్
|
||||
Next tab తదుపరి ట్యాబ్
|
||||
Previous tab మునుపటి ట్యాబ్
|
||||
Switch light / dark theme లేత / ముదురు థీమ్ల మధ్య మారు
|
||||
Comments panel వ్యాఖ్యల ప్యానెల్
|
||||
Form fields panel ఫారం ఫీల్డ్ల ప్యానెల్
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -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
|
||||
|
||||
|
@@ -78,6 +78,8 @@
|
||||
Column select Виділення стовпця
|
||||
Full screen mode Повноекранний режим
|
||||
Read mode Режим читання
|
||||
Next tab Наступна вкладка
|
||||
Previous tab Попередня вкладка
|
||||
Switch light / dark theme Перемкнути світлу / темну тему
|
||||
Comments panel Панель коментарів
|
||||
Form fields panel Панель полів форми
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 481 and column 12.
|
@@ -122,6 +122,8 @@
|
||||
Column select 列选择
|
||||
Full screen mode 全屏模式
|
||||
Read mode 阅读模式
|
||||
Next tab 下一个标签页
|
||||
Previous tab 上一个标签页
|
||||
Switch light / dark theme 切换浅色/深色主题
|
||||
Comments panel 注释面板
|
||||
Form fields panel 表单域面板
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -81,6 +81,8 @@
|
||||
Column select 欄選取
|
||||
Full screen mode 全螢幕模式
|
||||
Read mode 閱讀模式
|
||||
Next tab 下一個分頁
|
||||
Previous tab 上一個分頁
|
||||
Switch light / dark theme 切換淺色/深色主題
|
||||
Comments panel 註解面板
|
||||
Form fields panel 表單欄位面板
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>(), document_ids);
|
||||
}
|
||||
|
||||
/// Presses the named tab, drags it to `to` in a few steps and, unless `release` is false, lets go.
|
||||
fn drag_named_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str, to: egui::Pos2, release: bool) {
|
||||
let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect().center();
|
||||
h.event(egui::Event::PointerMoved(from));
|
||||
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for k in 1..=6 {
|
||||
h.event(egui::Event::PointerMoved(from.lerp(to, k as f32 / 6.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
if release {
|
||||
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_tab_moves_it_and_keeps_the_same_document_active() {
|
||||
use egui::accesskit::Role;
|
||||
let mut h = three_tab_harness();
|
||||
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
|
||||
let tab = |h: &Harness<'static, PdfCraftApp>, name: &str| h.get_by_role_and_label(Role::Tab, name).rect();
|
||||
// first.pdf past the end; last.pdf, the active one, keeps the focus at its new place.
|
||||
let to = tab(&h, "last.pdf").right_center() + egui::vec2(20.0, 0.0);
|
||||
drag_named_tab(&mut h, "first.pdf", to, true);
|
||||
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), [ids[1], ids[2], ids[0]]);
|
||||
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2]));
|
||||
assert!(tab(&h, "first.pdf").left() > tab(&h, "last.pdf").right(), "the strip draws the new order");
|
||||
// last.pdf, now in the middle, to the front.
|
||||
let to = tab(&h, "middle.pdf").left_center() - egui::vec2(10.0, 0.0);
|
||||
drag_named_tab(&mut h, "last.pdf", to, true);
|
||||
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), [ids[2], ids[1], ids[0]]);
|
||||
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2]));
|
||||
assert_eq!(h.get_by_role_and_label(Role::Tab, "last.pdf").accesskit_node().is_selected(), Some(true));
|
||||
// Moving tabs touches no document.
|
||||
assert_eq!(h.state().session.docs().len(), 3);
|
||||
for doc in h.state().session.docs() {
|
||||
assert_eq!(doc.bytes.as_slice(), FIXTURE);
|
||||
assert!(!doc.dirty);
|
||||
}
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
|
||||
let to = tab(&h, "first.pdf").left_center() - egui::vec2(10.0, 0.0);
|
||||
drag_named_tab(&mut h, "last.pdf", to, false);
|
||||
h.render().unwrap().save(format!("{dir}/dragging-tab.png")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it() {
|
||||
let mut h = three_tab_harness();
|
||||
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
|
||||
let middle = h.get_by_role_and_label(egui::accesskit::Role::Tab, "middle.pdf").rect();
|
||||
drag_named_tab(&mut h, "middle.pdf", middle.center() + egui::vec2(30.0, 0.0), true);
|
||||
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), ids);
|
||||
h.get_by_label("first.pdf").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[0]));
|
||||
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_combine_tab_does_not_move_and_documents_stay_in_order() {
|
||||
let mut h = three_tab_harness();
|
||||
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
|
||||
h.state_mut().execute("page.combine");
|
||||
h.run_steps(3);
|
||||
let first = h.get_by_role_and_label(egui::accesskit::Role::Tab, "first.pdf").rect();
|
||||
drag_named_tab(&mut h, "Combine files", first.left_center() - egui::vec2(10.0, 0.0), true);
|
||||
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), ids);
|
||||
}
|
||||
|
||||
+40
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-3
@@ -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());
|
||||
|
||||
+159
-8
@@ -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<PathBuf> =
|
||||
@@ -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::<f64>() / 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<PathBuf> = 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::<f64>() / 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<T: std::str::FromStr>(args: &[String], name: &str) -> Option<T> {
|
||||
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<'_>> {
|
||||
pdfcraft_oracle::pixels::Gray::new(w, h, buf)
|
||||
}
|
||||
|
||||
fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option<String> {
|
||||
// `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<Vec<u8>> {
|
||||
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<String> {
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
out.push_str(&reader.join().ok()?);
|
||||
out.extend_from_slice(&reader.join().ok()?);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
|
||||
+19
-2
@@ -198,11 +198,17 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user