Compare commits

..

No commits in common. "main" and "codex/craft-print" have entirely different histories.

298 changed files with 2198 additions and 41082 deletions

View File

@ -1,76 +0,0 @@
<!--
Thanks for contributing to PdfCraft.
Base this on `main` — it is the only integration branch. `main` moves fast (it took
396 commits in the three days to 2026-10-12), so a PR that sits for a day usually needs
a rebase. Rebasing the same day you are asked keeps the work from dying of conflicts.
Delete any section below that does not apply.
-->
## What this changes
<!-- What behaviour is different afterwards, and why it is worth doing. If it fixes an
issue, say `Fixes #123`. -->
## How you verified it
<!-- The commands you ran and what they said. Per-crate is fine and preferred:
`cargo test -p <crate>`. CI runs the full workspace on every PR, so you do not
need to locally. -->
- [ ] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed
- [ ] tests pass on the crates I changed
- [ ] `cargo xtask layers` and `cargo xtask assets` (if you touched crate boundaries or any asset)
**Tests.** New behaviour needs a test at the right scale — a property test, a unit test, a
crate integration test or an end-to-end one. For a bug fix, **please check that the test
fails before your fix** by reverting the fix and watching it go red. A test nobody has seen
fail is not evidence that it pins anything.
## Never-crash
Every PDF, script, MCP call, settings file and keystroke is untrusted input (`AGENTS.md` §4).
- [ ] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests, unless it is
provably infallible with a comment saying why
- [ ] No indexing or slicing with input-derived positions (`get`, and strings only at char
boundaries); checked or saturating arithmetic; allocations sized by input are capped;
recursion is bounded
- [ ] No `unsafe`
- [ ] Any crash this fixes has a small synthetic regression test
## Provenance
This is the part that cannot be undone after a merge, and the part a new contributor is
least likely to know about. Please read `AGENTS.md` §1 before adding any asset.
- [ ] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated
- [ ] No font files committed here (they live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts))
- [ ] No GPL/AGPL code copied in — MuPDF, Ghostscript, Poppler, veraPDF and DSS may only be
run as external oracle processes
- [ ] Behaviour comes from the specification or black-box observation, **not** from reading
another implementation's source
- [ ] Every new asset has an `ATTRIBUTION.toml` entry and `cargo xtask assets` passes
## Security
- [ ] `--root` confinement still holds on every path, including ones reached by nested
tool dispatch
- [ ] MCP and the UI control channel stay opt-in, loopback-only and token-authenticated
- [ ] No new outbound network access (that decision is the owner's)
## Parity and reach
- [ ] `parity/acrobat-features.toml` updated in this PR, with `notes` saying what is *still*
missing — `shipped` means "exists and tested", not "as good as Acrobat"
- [ ] Reachable headlessly through an `automation` tool where that applies (`AGENTS.md` §3),
so agents can drive it and not just the UI
- [ ] Docs updated if this changes a documented behaviour (`docs/`, crate README)
## Anything a reviewer should look at first
<!-- The judgement call you are least sure about, a deliberate restraint you want pinned,
or a behaviour change existing users will notice. Say so here rather than leaving it
to be found. -->

View File

@ -20,9 +20,6 @@ jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
strategy:
fail-fast: false
matrix:
@ -42,12 +39,6 @@ jobs:
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Configure craft-fonts (Linux)
if: runner.os == 'Linux'
run: |
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)
if: runner.os == 'Linux'
run: |
@ -81,10 +72,12 @@ jobs:
- name: Test (with craft-fonts)
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace
- name: Layering
if: runner.os == 'Linux'
run: cargo xtask layers
- name: Assets (licences and attribution)

View File

@ -35,9 +35,3 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

View File

@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
- Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs.
- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
- The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_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.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces, and are embedded as code in hayro.
Adding anything to this list needs the user's explicit approval.

View File

@ -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 (270)
## In this repository (269)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@ -27,7 +27,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@ -222,7 +221,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. |
| `vendor/hayro-interpret/assets/LAB.icc` | Lab abstract ICC profile | The Hayro Authors (generated with LittleCMS 2) | MIT OR Apache-2.0 | https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/) | Lab colour space conversion (vendored hayro-interpret) |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |

View File

@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "389982dcbb7f1ac29fb150c9af43f9529db6e59a091b9971f0401684847d8b53"
sha256 = "bd7d565bff34f6f73b90b0884f480b9f39b106d5c96953e17c68619f03385a57"
# 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 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91"
sha256 = "de11e8f3b126092b584beb8b22ea8952ff6cbc79d42c417663843be93c807bc4"
[[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 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad"
sha256 = "8ddfd156833e29d8584dab85520264d8e5d77bfcdb5ea994577acc1a9644936b"
[[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 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7"
sha256 = "67abad667c05dc10bc9bffab5302978bc73b8314f63f78b543cdece4b5cea6d7"
[[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 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0"
sha256 = "a8d35613c888904ce6e984560c06d54a3c6f9828fa840c733eb9f2f893b8c0ee"
[[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 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22"
sha256 = "9f8a39143000bc909ee24617518c41f3aefaaeace341002e403d94a9eb51c984"
[[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 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f"
sha256 = "a099eb8c6c4bdbd62dc32efef97b4bef718c0170492e6921568d48e46499c964"
[[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 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9"
sha256 = "e59dc41d7628503fd241689efb69641bcf31733a204f3cd492ac88d66c76c6a0"
[[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 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129"
sha256 = "c6ab6587ad3ecd56efb019a18eb71f92fca33c43ead8eb9fc3262b8c56e5beb1"
[[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 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3"
sha256 = "1fa0e8bba340d78ecf2a3c9ae319b34e99811082950ebf4aee5e7f5eb0bdab22"
[[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 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692"
sha256 = "4297893b78fa25368d9bb6e10485fb3ede866ed470e528001de0120ea2f0915b"
[[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 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b"
sha256 = "a84ccfd96c9704d1630d6b42b1c50298a0ea5791b063ff2bbe1ccc993f87cc47"
[[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 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e"
sha256 = "da6aee2a47ec15193a3d88f8116f2b81ec1ac815b9481eb9c36d48518625df30"
[[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 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9"
sha256 = "122cc1b0a346a8ff4cb7ede77775afbd326e82c9bfd6bda06f895ac100702144"
[[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 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e"
sha256 = "5eb3ccd3c7613fd54b4a217ddaf32f29a37efb29373787a3178209ae3aca12a5"
[[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 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3"
sha256 = "ffbf8a2c117d75b008361e882c96c9d7cad8844e791800a60174eacf96e3c89a"
[[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 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d"
sha256 = "424e5f16bead85250d142e791b5e8a874c9905025be052ed8aef1f1645e5585a"
[[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 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912"
sha256 = "63d26bec54aa6e9f9c4b6e4dc16b872a41ace7feec825c888633992ca1507148"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@ -217,18 +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 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor"
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 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64"
sha256 = "0b330a5e739159d02a8214af4655e954cba59861ca30b914614ffb0f2735e52d"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -2365,16 +2354,15 @@ 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."
path = "vendor/hayro-interpret/assets/LAB.icc"
title = "Lab abstract ICC profile"
author = "The Hayro Authors (generated with LittleCMS 2)"
source = "https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/)"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
licence_file = "vendor/hayro-interpret/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"
usage = "Lab colour space conversion (vendored hayro-interpret)"
sha256 = "a6efc5c30ece826eba2a83ddf5aa7026289b0c5ffa90acb129bc59ab8d7f5c59"
[[bundled]]
crate = "epaint_default_fonts"
@ -3351,4 +3339,4 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288"
sha256 = "7071df48ebdb362914f404deebebc84252550d10d08f333892584230efa6c1d6"

View File

@ -31,15 +31,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- No `unsafe` (`unsafe_code = "forbid"`).
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
- **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace`, and the wasm check once `xtask ci` exists (M0).
- **Commits:** one task id per commit (e.g. `M1.4: xref stream reader`). Commit only green states. End messages with the attribution line required by the environment.
## Running and looking at the app
- `cargo run -p pdfcraft -- <file.pdf>` opens the desktop app.

1209
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.7.1"
version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@ -15,39 +15,39 @@ repository = "https://github.com/storytold/pdfcraft"
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
# 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.6.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.6.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.6.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.6.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.6.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.6.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.6.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.6.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.6.0" }
pdfcraft-model = { path = "crates/model", version = "0.6.0" }
pdfcraft-content = { path = "crates/content", version = "0.6.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.6.0" }
pdfcraft-print = { path = "crates/print", version = "0.6.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.6.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.6.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.6.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.6.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.6.0" }
pdfcraft-js = { path = "crates/js", version = "0.6.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.6.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.6.0" }
pdfcraft-export = { path = "crates/export", version = "0.6.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.6.0" }
pdfcraft-create = { path = "crates/create", version = "0.6.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.6.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.6.0" }
pdfcraft-render = { path = "crates/render", version = "0.6.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.6.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.6.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.6.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.6.0", default-features = false }
# pdf
hayro = "0.7.1"

3
NOTICE
View File

@ -35,7 +35,8 @@ list (author, source, license) is in ATTRIBUTION.md.
- lopdf (bootstrap document inspection, via crates.io) — MIT.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
- Colour profiles in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram) and
LAB.icc (generated by the hayro authors with LittleCMS 2; MIT OR Apache-2.0).
Required notices:

View File

@ -133,8 +133,6 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
## Navigate long documents
Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents.
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
<table>
@ -424,7 +422,7 @@ PdfCraft is young and moving fast. The aim is a workbench where you can view, or
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs, web printing and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).

View File

@ -2,20 +2,8 @@
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@ -58,7 +46,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert
and share (no in-app Office→PDF; Word export loses layout, #773; password-protected files open in
Acrobat since #836, #774). Those, unmeasured quality against Acrobat, the borrowed `hayro` renderer,
and 245 open issues keep it far from beta.
no printing on the web and 245 open issues keep it far from beta.
### Readiness by audience
@ -138,7 +126,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| Rank | Milestone | Estimate (h) |
|---|---|---|
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 |
| 1 | Backlog and platform: crash and wrong-result reports, web printing, Chinese UI face (#826) | 65–120 |
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
| 3 | Editing existing content (M7) | 100–180 |
| 4 | OCR languages and Office export/import (M10) | 95–175 |
@ -149,63 +137,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
- **2026-10-12 (M3, #1364 candidate):** Absolute local GoToR links wait for Allow/Cancel before target-file access. Pending requests preserve their source and page/view destination; relative links retain their existing behavior. Headless `link_open` requires explicit `allow_absolute: true`, preserving existing root, symlink, file and password validation. Estimates unchanged.
- **2026-10-12 (community, M7, inline Arabic ActualText):** A single-glyph lam-alef `/ActualText` span joins its surrounding word in Edit text instead of splitting it into separate lines. Pure RTL ligature text is put in visual order before line assembly and recovered to typing order with its neighbours. Multi-glyph and mixed-direction replacements retain their authoritative order. Synthetic regressions cover lam-alef variants, marks, two-byte glyph codes, nested spans, replacement and save/reopen. Estimates unchanged.
- **2026-10-12 (M5, #1398):** A stamp placed on a page with `/Rotate` reads upright as that page is displayed. Rubber stamps (including Sign Here), natural-size and explicit-rectangle images, and PDF-page stamps counterrotate their appearance; restyling a picture stamp keeps that correction, and a saved file reopens the same way. Estimates unchanged.
- **2026-10-12 (M10, file formats):** Create PDF reads BigTIFF scans; a malformed GIF header is no longer detected as an image only to fail conversion. file-format-parity.md now lists XDP data import/export for XFA forms (already shipped under `form.xfa-data`) separately from opening `.xdp` as a document. Estimates unchanged.
- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged.
- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged.
- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged.
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged.
- **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged.
- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged.
- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged.
- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged.
- **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open.
- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged.
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
- **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged.
- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged.
- **2026-10-11 (M5, #1191):** Quick-bar colour changes now restyle the selected comment when it matches the active comment tool, while continuing to update that tool's default; added an accessible label to the swatch controls and a UI regression proving the saved style and undo step. Estimates unchanged.
- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged.
- **2026-10-11 (contributor fork, M7):** Full matching font import completes source-subset glyphs; installed/file font selection, TrueType/OpenType-CFF mapping, disjoint range styles, marked paragraph boundaries and single-face contextual shaping/bidi added. Private Chinese keyboard edits on all four pages preserve source anchors and save/reopen renders. Curved clips exclude holes from hit tests; shared path scopes are isolated for image transforms without moving neighbouring paint. No font binary or private fixture is published; release font inputs and readiness estimates unchanged.
- **2026-10-11 (M7, cropped image resize follow-up):** Resize from the visible (clipped) frame; an image's own clip moves with it (#1049) and shared clips stay fixed. Selected corner grips keep control over overlapping text; horizontal/vertical-only shrinking responds without freezing; one drag frame is previewed and one edit is applied on release. Synthetic pointer/tool regressions and private grow/shrink/save/reopen/undo rendering checks cover the regression. Shared clipping scopes containing unrelated painted content are refused. Estimates unchanged.
- **2026-10-11 (M7, cropped artwork selection):** Image/Form inventories now expose page/path-clipped visible bounds while retaining full placement geometry for transforms. Edit clicks prioritize text over background artwork; image frames stay within their own page and fixed clips remain fixed during movement. Engine/tool/UI regressions and an opt-in four-page private-file test cover direct access to 73 text regions and five cropped placements. Rectangular clips are exact; curved/complex clips use conservative bounds. Estimates unchanged.
- **2026-10-11 (M7, live text editor follow-up):** Reuse validated embedded sfnt source faces; adapt Mac format-6 subset cmaps through PDF Unicode mappings in memory without changing or installing source fonts. Preserve source baseline positions on entry. Show draft text and a single frame that grows/shrinks as rows are added or removed; single rows can grow toward the page edge. `text_edit_layout` exposes read-only draft bounds through the engine and automation. Licensed synthetic font/frame regressions and an opt-in all-page private-file check cover source font usage, stable positions, typing and deletion. Unsupported source programs, shaping and installed-font selection remain limited; estimates unchanged.
- **2026-10-11 (M7, mixed-style regions follow-up):** Group adjacent source runs into visual rows and aligned rows into regions across subset fonts, colours, weights and text-matrix sizes. One inline TextEdit uses styled spans and source row breaks; rewriting preserves surviving source-run styles, source subset encodings and centered mixed-size title alignment. Added engine, automation and UI regressions for edit/delete, read-only preview, save/reopen and undo; privately verified the supplied real PDF's three-row title and six-row body, including selecting both in one editor without extra rows. `text_paragraphs` exposes `editor_text` and `rows`. Installed-font selection, exact editor glyphs, complex shaping and arbitrary multi-range style edits remain open. Readiness estimates unchanged.
- **2026-10-11 (M7, contributor fork):** Join compatible words across text objects and subset-font spaces; preserve source fonts that lack a space glyph using text positioning; recognize component colour operators. Inline editing uses one editor frame, resets its available width when switching blocks, keeps source line breaks, and renders a COS-clone background without the original glyphs, preserving unselected text advances. `page_render.hide_text_block` exposes the same read-only preview. Mixed-style paragraph grouping, installed-font selection and exact source-font editor rendering remain open.
Newest first: the date, what moved, and the new overall estimate.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged.
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes.
- **2026-10-11 (M3, animation effects):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has its own Animation effects: Use system setting (the default), On or Off. With animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, the chrome's hover and press washes are simply there, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. The preference set to On or Off decides all of this whatever the system says; it is saved, set as `animation_effects` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting animates there as before; Off works everywhere. Estimates unchanged.
- **2026-10-11 (M10, gap 4):** The web build prints. Its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. PdfCraft draws the print-ready PDF's sheets itself with its own renderer, a sheet a frame because the web build has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 megapixels a sheet and 1 GiB of pictures a job. It then prints them from a hidden iframe, one sheet per printed page with no margin. A landscape sheet among portrait ones is turned onto the paper, as a driver does, because Chromium prints every page at the default `@page` size. Copies, two-sided and colour are set in the browser's dialog, so the dialog greys them out. If the browser refuses to print (a sandboxed frame, for example), the print-ready PDF downloads instead. Verified in headless Chromium: the right sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Not yet tested in Firefox or Safari. Gap 4 closed; rank 1 is 65–120 → 50–90 h; other estimates unchanged.
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
- **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged.
- **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged.
@ -223,8 +160,6 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
- **2026-10-11 (M10, #773 integration):** Merged current upstream updates into the Word export fix and adopted the shared Windows symlink test setup. Word layout scope and estimates unchanged.
- **2026-10-11 (M10, #773):** Word export no longer invents visible borders for tables inferred from text alignment. Rotated text, and pages where text overlaps an image or a watermark-sized text block, use positioned, editable shapes so invoice watermarks do not reserve body space (pages whose logo or title sits clear of the text keep flowing paragraphs, tables and inline images); source pages have separate sizes and section breaks. Synthetic export and automation regressions cover the invoice failure. Exact font matching, vector rules and general layout fidelity remain partial; estimates unchanged.
- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged.
- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged.
- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged.
@ -427,15 +362,8 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged |
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
| 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. |
| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed (wheel scrolling and zooming without easing, no other animations), and Preferences has Animation effects (Use system setting, On, Off). Estimates unchanged. |
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
@ -443,5 +371,3 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
- **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags.

View File

@ -1,61 +0,0 @@
//! Friendly flags for the same folder-job tool exposed by `run` and MCP.
use serde_json::json;
use crate::{CliError, OptionSpec, automation, flag, print_output, validate_options};
const OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--root", takes_value: true },
OptionSpec { name: "--out-dir", takes_value: true },
OptionSpec { name: "--steps", takes_value: true },
OptionSpec { name: "--recursive", takes_value: false },
OptionSpec { name: "--stop-on-error", takes_value: false },
OptionSpec { name: "--ppi", takes_value: true },
OptionSpec { name: "--quality", takes_value: true },
OptionSpec { name: "--dpi", takes_value: true },
OptionSpec { name: "--language", takes_value: true },
OptionSpec { name: "--ocr-all-pages", takes_value: false },
];
pub(super) fn run(args: &[String]) -> Result<(), CliError> {
validate_options("batch", args, OPTIONS)?;
let mut paths = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut i = 0;
while let Some(arg) = args.get(i) {
if let Some(spec) = OPTIONS.iter().find(|s| s.name == arg) {
if !seen.insert(spec.name) {
return Err(format!("batch: {} may only be supplied once", spec.name).into());
}
if spec.takes_value {
if args.get(i + 1).is_none_or(|v| v.is_empty() || v.starts_with("--")) {
return Err(format!("batch: {} needs a value", spec.name).into());
}
i += 1;
}
} else {
paths.push(arg);
}
i += 1;
}
let steps: Vec<_> = flag(args, "--steps").ok_or("batch: missing --steps (convert, ocr, optimize)")?.split(',').collect();
let out = flag(args, "--out-dir").ok_or("batch: missing --out-dir")?;
let mut params =
json!({"paths":paths,"steps":steps,"out_dir":out,"recursive":seen.contains("--recursive"),"stop_on_error":seen.contains("--stop-on-error")});
for (option, key) in [("--ppi", "ppi"), ("--quality", "quality"), ("--dpi", "dpi")] {
if let Some(v) = flag(args, option) {
params[key] = serde_json::from_str(v).map_err(|_| format!("batch: {option} needs a number"))?;
}
}
if let Some(lang) = flag(args, "--language") {
params["language"] = json!(lang);
}
if seen.contains("--ocr-all-pages") {
params["skip_text_pages"] = json!(false);
}
let mut auto = automation(args)?;
let content = auto.call("doc_batch", &params).map_err(|e| e.to_string())?;
let failed = content.iter().any(|c| matches!(c, pdfcraft_automation::Content::Json(v) if v["failed"].as_u64().is_some_and(|n| n > 0)));
print_output(&auto, content, None)?;
if failed { Err("batch: one or more files failed; see the JSON report".into()) } else { Ok(()) }
}

View File

@ -17,8 +17,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod batch;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
@ -53,15 +51,11 @@ pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .t
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
[--insert-blank 1] [--title T] [--author A] [--full]
(options run in order; page numbers refer to the document after the earlier options)
pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
pdfcraft-cli audit <file.pdf> [--password PW] find faux redactions (fails when found)
pdfcraft-cli batch <files or dirs...> --steps convert,ocr,optimize --out-dir DIR
[--recursive] [--root DIR] [--stop-on-error]
[--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages]
pdfcraft-cli tools automation tools and their JSON Schemas
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
@ -95,7 +89,6 @@ fn main() -> ExitCode {
Some("audit") => audit(&args[1..]),
Some("tools") => tools(),
Some("run") => run(&args[1..]),
Some("batch") => batch::run(&args[1..]),
Some("ui") => ui(&args[1..]),
#[cfg(feature = "mcp")]
Some("mcp") => mcp(&args[1..]),
@ -148,7 +141,7 @@ fn stdout_line(line: std::fmt::Arguments<'_>) -> Result<(), CliError> {
}
fn version() -> Result<(), CliError> {
stdout_line(format_args!("pdfcraft-cli {}", pdfcraft_engine::build_info::version()))?;
stdout_line(format_args!("pdfcraft-cli {}", env!("CARGO_PKG_VERSION")))?;
stdout_line(format_args!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD))?;
stdout_line(format_args!("Web: {}", pdfcraft_engine::links::APP_PAGE))?;
stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?;
@ -158,7 +151,6 @@ fn version() -> Result<(), CliError> {
/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
fn audit(args: &[String]) -> Result<(), CliError> {
validate_options("audit", args, AUDIT_OPTIONS)?;
let path = *positional(args).first().ok_or("audit: missing file")?;
let bytes = read(path)?;
let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
@ -221,7 +213,6 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
}
const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
const EXTRACT_OPTIONS: &[OptionSpec] = &[
@ -338,20 +329,11 @@ fn text(args: &[String]) -> Result<(), CliError> {
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
// `inspect` why instead, so a protected file fails the same way `info` does.
if r.page_count() == 0 {
let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?;
let info = inspect(bytes, password).map_err(|e| format!("text: {e}"))?;
if !info.pages.is_empty() {
return Err("text: the document could not be parsed".into());
}
}
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
// permissions password, a document that doesn't allow copying gives no text.
let mut session = pdfcraft_engine::Session::new();
if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
// The engine's hint is about editing in the app; on the command line the way in is
// `--password`.
doc.check_copying()
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
}
let pages: Vec<usize> = match selected_page {
Some(page) => vec![page],
None => (0..r.page_count()).collect(),
@ -444,28 +426,6 @@ fn edit(args: &[String]) -> Result<(), CliError> {
let mut session = Session::new();
let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
for e in edits {
// A destination past the end must fail like `page_move` / `page_insert_blank` do, not
// silently become an append (#1330). Checked per edit: earlier edits change the count.
let n = session.get(id).map_or(0, |d| d.info.pages.len());
let destination = match &e {
Edit::MovePages { to, .. } => Some(("--move target", *to)),
Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
_ => None,
};
if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
}
// Pages count from the document as the earlier options left it, so a page past the end is
// reported with its own option and the current page count (#1550).
let listed = match &e {
Edit::RotatePages { pages, .. } => Some(("--rotate", pages)),
Edit::DeletePages { pages } => Some(("--delete", pages)),
Edit::MovePages { pages, .. } => Some(("--move", pages)),
_ => None,
};
if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) {
return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into());
}
session.apply(id, e).map_err(|e| e.to_string())?;
}
let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
@ -637,20 +597,8 @@ fn short(s: &str) -> String {
fn collect(paths: &[&str]) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let mut visited = std::collections::HashSet::new();
while let Some(p) = stack.pop() {
if p.is_dir() {
// Directory aliases and symlink cycles must not scan the same tree repeatedly.
let identity = match p.canonicalize() {
Ok(identity) => identity,
Err(error) => {
eprintln!("{}: could not resolve directory: {error}", p.display());
continue;
}
};
if !visited.insert(identity) {
continue;
}
if let Ok(rd) = std::fs::read_dir(&p) {
stack.extend(rd.flatten().map(|e| e.path()));
}

View File

@ -1,46 +0,0 @@
use serde_json::Value;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
#[test]
fn cli_batch_reports_partial_failure_and_continues_into_subfolders() {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-batch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("input/sub")).unwrap();
std::fs::write(dir.join("input/0.pdf"), b"broken").unwrap();
std::fs::write(dir.join("input/sub/page.txt"), "Batch text").unwrap();
let output = Command::new(BIN)
.args(["batch", "input", "--root"])
.arg(&dir)
.args(["--steps", "convert,optimize", "--out-dir", "out", "--recursive"])
.output()
.unwrap();
assert!(!output.status.success());
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["failed"], 1);
let converted = dir.join("out/sub/page.pdf");
assert!(converted.exists());
let text = Command::new(BIN).arg("text").arg(converted).output().unwrap();
assert!(text.status.success());
assert!(String::from_utf8_lossy(&text.stdout).contains("Batch text"));
let success = Command::new(BIN).current_dir(&dir).args(["batch", "input/sub", "--steps", "convert", "--out-dir", "other"]).output().unwrap();
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn cli_batch_rejects_bad_flags_before_creating_outputs() {
for args in [
vec!["batch", "--steps"],
vec!["batch", "--recursive", "--root"],
vec!["batch", "--steps", "convert", "--out-dir"],
vec!["batch", "--steps", "convert", "--steps", "ocr", "--out-dir", "out"],
vec!["batch", "--unknown"],
] {
let output = Command::new(BIN).args(args).output().unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
}

View File

@ -1,111 +0,0 @@
//! The robustness harness visits each directory once, including symlink aliases and cycles.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
struct Workdir(PathBuf);
impl Workdir {
fn new(test: &str) -> Self {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-check-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("input/nested")).unwrap();
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 20 30] >>",
"<< /Type /Page /Parent 2 0 R >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
std::fs::write(dir.join("input/a.pdf"), &bytes).unwrap();
std::fs::write(dir.join("input/nested/b.PDF"), &bytes).unwrap();
Self(dir)
}
fn check(&self, inputs: &[&Path]) -> Vec<serde_json::Value> {
let report = self.0.join("report.json");
let mut child = Command::new(BIN)
.arg("check")
.args(inputs)
.args(["--dpi", "36", "--timeout", "2", "--json"])
.arg(&report)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while child.try_wait().unwrap().is_none() {
if Instant::now() >= deadline {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
panic!("check did not finish scanning directories: {}", String::from_utf8_lossy(&output.stderr));
}
std::thread::sleep(Duration::from_millis(10));
}
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
serde_json::from_slice(&std::fs::read(report).unwrap()).unwrap()
}
}
impl Drop for Workdir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn assert_two_valid_pdfs(results: &[serde_json::Value]) {
assert_eq!(results.len(), 2, "each PDF should be checked once: {results:?}");
for result in results {
assert_eq!(result["status"], "ok", "{result}");
assert_eq!(result["pages"], 1, "{result}");
}
assert!(results[0]["file"].as_str().unwrap().ends_with("a.pdf"));
assert!(results[1]["file"].as_str().unwrap().ends_with("b.PDF"));
}
#[test]
fn check_scans_overlapping_directories_once() {
let dir = Workdir::new("overlap");
let input = dir.0.join("input");
assert_two_valid_pdfs(&dir.check(&[&input, &input, &input.join("nested")]));
}
#[cfg(unix)]
#[test]
fn check_scans_a_directory_symlink_cycle_once() {
let dir = Workdir::new("cycle");
let input = dir.0.join("input");
std::os::unix::fs::symlink(".", input.join("again")).unwrap();
assert_two_valid_pdfs(&dir.check(&[&input]));
}
#[cfg(unix)]
#[test]
fn check_follows_directory_and_file_symlinks() {
let dir = Workdir::new("aliases");
let input = dir.0.join("input");
let alias = dir.0.join("alias");
std::os::unix::fs::symlink(&input, &alias).unwrap();
assert_two_valid_pdfs(&dir.check(&[&input, &alias]));
let file_alias = dir.0.join("page.pdf");
std::os::unix::fs::symlink(input.join("a.pdf"), &file_alias).unwrap();
let results = dir.check(&[&file_alias]);
assert_eq!(results.len(), 1);
assert_eq!(results[0]["status"], "ok");
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
}

View File

@ -179,63 +179,3 @@ mod one_based_arguments {
assert_eq!(std::fs::read(dir.0.join("input.pdf")).unwrap(), source);
}
}
/// A destination past the end must fail, not silently become an append (#1330).
#[test]
fn a_destination_past_the_end_fails_and_writes_nothing() {
let input = tmp("past-end", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
let output = tmp("past-end", "out.pdf");
for (option, value, diagnostic) in [
("--insert-blank", "3", "--insert-blank 3 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--insert-blank", "99", "--insert-blank 99 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--move", "1:3", "--move target 3 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--move", "1:99", "--move target 99 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
] {
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{option} {value}");
assert_eq!(String::from_utf8_lossy(&out.stderr), format!("pdfcraft-cli: {diagnostic}\n"), "{option} {value}");
assert!(!output.exists(), "{option} {value}: no output may be written");
}
}
/// One past the last page appends, and each destination is checked against the page count at that point.
#[test]
fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
let input = tmp("append", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
for (name, args) in [
("insert.pdf", vec!["--insert-blank", "2"]),
("move.pdf", vec!["--insert-blank", "1", "--move", "1:3"]),
("grown.pdf", vec!["--insert-blank", "2", "--insert-blank", "3"]),
] {
let output = tmp("append", name);
let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
all.extend(args);
let out = Command::new(BIN).args(&all).output().unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
let sizes: Vec<_> = info.pages.iter().map(|p| (p.width, p.height)).collect();
let expected: &[(f32, f32)] = match name {
"insert.pdf" => &[(200.0, 300.0), (612.0, 792.0)],
"move.pdf" => &[(200.0, 300.0), (612.0, 792.0)],
_ => &[(200.0, 300.0), (612.0, 792.0), (612.0, 792.0)],
};
assert_eq!(sizes, expected, "{name}");
}
}
/// A page past the end names its own option and the page count left by the earlier options (#1550).
#[test]
fn a_page_past_the_end_after_earlier_edits_names_that_option() {
let input = tmp("chained", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
let output = tmp("chained", "out.pdf");
// 1 page, grown to 3, then --delete leaves 2: the 3 in --move 3:1 is the page that is gone.
let args = ["--insert-blank", "2", "--insert-blank", "3", "--delete", "3", "--move", "3:1"];
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()]).args(args).output().unwrap();
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--move: page 3 does not exist") && err.contains("has 2 pages"), "{err}");
assert!(!output.exists());
}

View File

@ -37,18 +37,6 @@ fn listed_tools(flags: &[&str]) -> Vec<String> {
reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn mcp_rejects_unknown_options_before_starting_the_server() {
for option in ["--totally-bogus-flag", "--rot", "--root=."] {
let out = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["mcp", option]).output().expect("run CLI");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "mcp {option} unexpectedly succeeded");
assert!(stderr.contains("unknown option") && stderr.contains(option), "mcp {option}: {stderr}");
assert!(!stderr.contains("MCP server on stdio"), "server started despite invalid option: {stderr}");
}
}
#[test]
fn compact_flag_shortens_the_tool_list() {
let full = listed_tools(&[]);

View File

@ -15,8 +15,6 @@ fn non_edit_commands_reject_unknown_options_before_io() {
vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"],
vec!["check-one", "missing.pdf", "--edti"],
vec!["check", "missing.pdf", "--timout", "2"],
vec!["audit", "missing.pdf", "--passwrod", "x"],
vec!["audit", "missing.pdf", "--nonsense", "1"],
vec!["run", "doc_open", "--roto", "/tmp"],
vec!["ui", "--contro", "control.json", "state"],
] {

View File

@ -96,41 +96,6 @@ fn text_of_a_protected_document_with_the_password_succeeds() {
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
/// `three.pdf` encrypted so that the open password `openme` doesn't allow copying, and the
/// permissions password `boss` lifts that.
fn copy_protected_fixture() -> std::path::PathBuf {
let src = tmp("to-protect-no-copy.pdf");
std::fs::write(&src, fixture(3)).unwrap();
let out_path = tmp("protected-no-copy.pdf");
let script = tmp("protect-no-copy.json");
let steps = serde_json::json!([
{ "tool": "doc_open", "args": { "path": src } },
{ "tool": "doc_protect", "args": { "doc": 1, "open_password": "openme", "permissions_password": "boss", "copy": false } },
{ "tool": "doc_save", "args": { "doc": 1, "path": out_path } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(BIN).args(["run", "--script", script.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "protecting the fixture failed: {}", String::from_utf8_lossy(&out.stderr));
out_path
}
/// `text` copies text out, so it asks what `text_extract` asks (#1645): the open password alone
/// gives no text when copying isn't allowed, the permissions password does.
#[test]
fn text_respects_a_document_that_does_not_allow_copying() {
let path = copy_protected_fixture();
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "openme"]).output().unwrap();
assert!(!out.status.success(), "copying isn't allowed: {}", String::from_utf8_lossy(&out.stdout));
assert!(out.stdout.is_empty(), "{}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("don't allow copying"), "the reason must be named: {stderr}");
assert!(stderr.contains("--password"), "and the way in: {stderr}");
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "boss"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
mod page_argument_tests {
use super::*;
use std::path::PathBuf;

View File

@ -14,8 +14,6 @@ workspace = true
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
rustls-native-certs = "0.8"
pdfcraft-ui-egui = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
@ -44,10 +42,6 @@ pollster = "1.0"
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
windows = { version = "0.62", default-features = false, features = ["std", "UI_ViewManagement"] }
# Single instance (#282): later launches hand their files to the running app over a Unix domain
# socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which
# our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping.

View File

@ -1,70 +0,0 @@
//! Who draws the window frame on Linux.
//!
//! Wayland compositors that draw frames for their clients offer `zxdg_decoration_manager_v1`
//! (KDE, Sway, Hyprland, COSMIC). GNOME doesn't: it leaves the frame to the app, and winit then
//! drew an imitation of GNOME's. Without that protocol PdfCraft turns winit's frame off and draws
//! a header bar like GNOME's own apps (`pdfcraft_ui_egui::window_controls`), with the window
//! buttons GNOME's `button-layout` names. X11 window managers always draw the frame.
//!
//! `PDFCRAFT_TITLEBAR=app` or `=system` overrides the choice.
use pdfcraft_ui_egui::window_controls::Layout;
/// The window buttons to draw in the app, or `None` when the desktop draws the frame.
pub(crate) fn app_drawn() -> Option<Layout> {
let drawn = match std::env::var("PDFCRAFT_TITLEBAR").ok().as_deref().map(str::trim) {
Some("app") => true,
Some("system") => false,
_ => compositor_leaves_frame_to_app(),
};
drawn.then(button_layout)
}
/// GNOME's button order, or minimize, maximize and close on the right when it names none.
fn button_layout() -> Layout {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
if let Some(value) = crate::text_scale::gsettings(&["get", "org.gnome.desktop.wm.preferences", "button-layout"]) {
let layout = Layout::parse(&value);
// A layout without a single button would leave no way to close the window by mouse.
if !layout.left.is_empty() || !layout.right.is_empty() {
return layout;
}
}
Layout::default()
}
/// Whether this is a Wayland session whose compositor draws no frames.
#[cfg(target_os = "linux")]
fn compositor_leaves_frame_to_app() -> bool {
use wayland_client::Connection;
use wayland_client::globals::registry_queue_init;
// No Wayland compositor: X11, whose window manager draws the frame (winit falls back to it).
let Ok(conn) = Connection::connect_to_env() else { return false };
let Ok((globals, _queue)) = registry_queue_init::<Registry>(&conn) else { return false };
let offered = globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"));
if !offered {
log::info!("the Wayland compositor draws no window frames; PdfCraft draws its own title bar");
}
!offered
}
#[cfg(not(target_os = "linux"))]
fn compositor_leaves_frame_to_app() -> bool {
false
}
#[cfg(target_os = "linux")]
struct Registry;
#[cfg(target_os = "linux")]
impl wayland_client::Dispatch<wayland_client::protocol::wl_registry::WlRegistry, wayland_client::globals::GlobalListContents> for Registry {
fn event(
_: &mut Self,
_: &wayland_client::protocol::wl_registry::WlRegistry,
_: wayland_client::protocol::wl_registry::Event,
_: &wayland_client::globals::GlobalListContents,
_: &wayland_client::Connection,
_: &wayland_client::QueueHandle<Self>,
) {
}
}

View File

@ -32,10 +32,7 @@ use pdfcraft_ui_egui::PdfCraftApp;
#[cfg(target_os = "macos")]
mod apple_events;
mod compositor_gpu;
mod decorations;
mod logging;
#[cfg(target_os = "windows")]
mod motion;
mod reopen;
mod single_instance;
mod text_scale;
@ -127,35 +124,6 @@ fn path_from_arg(arg: String) -> String {
String::from_utf8(out).unwrap_or(arg)
}
/// Some programs start the PDF reader with the path unquoted (`pdfcraft.exe P:\Docs\01 Contract.PDF`),
/// so a path with spaces arrives split into several arguments (Acrobat accepts this). When an
/// argument names no file but it and the ones after it, joined with one space, do, rejoin them
/// (the longest such run). Anything else is kept as given. Runs of several spaces can't be recovered: the split has lost them.
fn rejoin_split_paths(args: Vec<String>, exists: impl Fn(&str) -> bool) -> Vec<String> {
let mut out = Vec::with_capacity(args.len());
let mut i = 0;
while let Some(first) = args.get(i) {
if exists(first) {
out.push(first.clone());
i += 1;
continue;
}
// The longest run starting here that names a file; capped so a long list stays cheap.
let end = (i + 2..=args.len().min(i + 32)).rev().find(|&j| args.get(i..j).is_some_and(|run| exists(&run.join(" "))));
match end.and_then(|j| Some((j, args.get(i..j)?.join(" ")))) {
Some((j, joined)) => {
out.push(joined);
i = j;
}
None => {
out.push(first.clone());
i += 1;
}
}
}
out
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
@ -185,7 +153,7 @@ fn main() -> eframe::Result {
while let Some(a) = args.next() {
match a.as_str() {
"--version" => {
println!("pdfcraft {}", pdfcraft_engine::build_info::version());
println!("pdfcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--control" => control_file = args.next(),
@ -198,17 +166,14 @@ fn main() -> eframe::Result {
_ => files.push(path_from_arg(a)),
}
}
let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
let integrated = cfg!(target_os = "macos");
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
migrate_legacy_folders();
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
// Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()),
Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
@ -223,7 +188,7 @@ fn main() -> eframe::Result {
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated, window_controls };
let launch = Launch { files, options, control_file, create_images, integrated };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@ -233,7 +198,7 @@ fn main() -> eframe::Result {
let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu };
let result = eframe::run_native(
"PdfCraft",
native_options(launch.integrated, launch.window_controls.is_some(), first),
native_options(integrated, first),
app_creator(
launch.clone(),
Rc::clone(&started),
@ -251,7 +216,7 @@ fn main() -> eframe::Result {
log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu.");
eframe::run_native(
"PdfCraft",
native_options(launch.integrated, launch.window_controls.is_some(), eframe::Renderer::Glow),
native_options(integrated, eframe::Renderer::Glow),
app_creator(
launch,
started,
@ -291,7 +256,7 @@ fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool {
}
/// The window and renderer settings for one run.
fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
@ -307,10 +272,6 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
if integrated {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
if app_frame {
// The tab strip is the title bar (pdfcraft_ui_egui::window_controls).
viewport = viewport.with_decorations(false);
}
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
// Set explicitly, so the renderer never depends on which one eframe defaults to.
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
@ -328,8 +289,6 @@ struct Launch {
control_file: Option<String>,
create_images: bool,
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
}
fn app_creator<'a>(
@ -338,7 +297,7 @@ fn app_creator<'a>(
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated, window_controls } = launch;
let Launch { files, options, control_file, create_images, integrated } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@ -352,14 +311,7 @@ fn app_creator<'a>(
app.restore(&json);
}
app.integrated_titlebar = integrated;
app.window_controls = window_controls;
app.system_text_scale = text_scale::system();
// Windows' Animation effects setting, which Preferences ▸ Animation effects follows unless
// it is set to On or Off.
#[cfg(target_os = "windows")]
{
app.motion.source = Some(motion::source());
}
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
@ -524,11 +476,6 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// its window appeared. D3D12 is the native, best-supported backend there.
fn configure_gpu(native: &mut eframe::NativeOptions) {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
// Pro: the device is lost and the window falls back to OpenGL. `WGPU_VALIDATION_INDIRECT_CALL=1`
// turns the check back on.
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
// egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces.
// A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in
// Surface::configure before the app starts. Keep the renderer's other device requirements,
@ -725,19 +672,6 @@ mod tests {
Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
}
/// Tests that open a real GPU device take this first, so only one of them is inside the
/// graphics libraries at a time. The test threads otherwise run them together, and on
/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
/// the first device was still being set up when the second test began its own. The UI
/// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole
/// test (declared before the instance, so it's released after the device is dropped).
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn gpu() -> std::sync::MutexGuard<'static, ()> {
// A test that panicked while holding it leaves nothing to clean up.
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn a_control_channel_that_cant_be_published_stops_the_app() {
let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
@ -772,24 +706,6 @@ mod tests {
assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
}
#[test]
fn unquoted_paths_with_spaces_are_rejoined() {
let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
let exists = |p: &str| have.contains(&p);
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
// The case an office program produced: one path, split at its space.
assert_eq!(
super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists),
args(&["P:\\Archive\\00042A26 Contract.PDF"])
);
// The longest run wins; files that exist on their own are left alone.
assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
// Nothing to join: kept as given, so the missing file is still reported by name.
assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
}
#[test]
fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
@ -815,15 +731,8 @@ mod tests {
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
assert!(!retry_with_gl(RendererChoice::Gl, false));
// Each run states its renderer rather than relying on eframe's default.
assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
}
#[test]
fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
use super::native_options;
assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
}
#[test]
@ -851,10 +760,6 @@ mod tests {
if std::env::var_os("WGPU_POWER_PREF").is_none() {
assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
}
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let flags = setup.instance_descriptor.flags;
assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
}
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
let backends = setup.instance_descriptor.backends;
assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
@ -973,8 +878,7 @@ mod tests {
// and an error wgpu raises before anything reaches the driver: a texture one pixel wider
// than the device allows.
use eframe::wgpu;
let _gpu = gpu();
let native = super::native_options(false, false, eframe::Renderer::Wgpu);
let native = super::native_options(false, eframe::Renderer::Wgpu);
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
panic!("default setup creates its own instance")
};
@ -1040,8 +944,7 @@ mod tests {
#[test]
fn pipeline_creation_error_is_returned_and_device_remains_usable() {
use eframe::{egui_wgpu, wgpu};
let _gpu = gpu();
let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
let mut native = super::native_options(false, eframe::Renderer::Wgpu);
let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };

View File

@ -1,75 +0,0 @@
//! The system's "no animations" setting, which Preferences ▸ Animation effects follows unless it is
//! set to On or Off (`pdfcraft_ui_egui::motion`).
//!
//! Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (in the Control Panel,
//! "Show animations in Windows"), read through WinRT's `UISettings.AnimationsEnabled`. Other
//! systems aren't asked yet: there the window animates as before.
use pdfcraft_ui_egui::motion::MotionSource;
/// Asks the system each time, and logs the answer when it changes.
pub fn source() -> MotionSource {
let last = std::cell::Cell::new(None);
Box::new(move || {
let reduced = reduced();
if reduced.is_some() && last.replace(reduced) != reduced {
log::info!("system animation effects: {}", if reduced == Some(true) { "off" } else { "on" });
}
reduced
})
}
/// Whether the system asks for no animations; `None` when it can't be asked.
pub fn reduced() -> Option<bool> {
match animations_enabled() {
Ok(enabled) => Some(!enabled),
Err(e) => {
log::debug!("system animation effects: {e}");
None
}
}
}
fn animations_enabled() -> windows::core::Result<bool> {
windows::UI::ViewManagement::UISettings::new()?.AnimationsEnabled()
}
#[cfg(test)]
mod tests {
/// The user's saved Animation effects choice, or `None` when `reg` can't read it. Windows
/// keeps its on/off system parameters as the bits of `UserPreferencesMask`: bit n is the
/// parameter `SPI_GET…` 0x1000 + 2n, and client-area animation is 0x1042, so bit 33.
fn saved_choice() -> Option<bool> {
let out = std::process::Command::new("reg").args(["query", r"HKCU\Control Panel\Desktop", "/v", "UserPreferencesMask"]).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let hex = text.split_whitespace().skip_while(|word| *word != "REG_BINARY").nth(1)?;
let byte = u8::from_str_radix(hex.get(8..10)?, 16).ok()?;
Some(byte & 0b10 != 0)
}
#[test]
fn the_answer_is_the_systems_animation_effects_setting() {
let answer = super::reduced();
// The first answer is logged and later ones only when they change; the answers don't.
let source = super::source();
assert_eq!((source(), source()), (answer, answer));
match super::animations_enabled() {
// With Animation effects turned off, the answer must be "reduced". Only this way
// round: the session's own value may be off for other reasons (a policy, a remote
// session) while the saved choice is on.
Ok(enabled) => {
assert_eq!(answer, Some(!enabled));
if saved_choice() == Some(false) {
assert!(!enabled, "Animation effects are off in Settings, but the answer was that animations are on");
}
}
Err(e) => {
eprintln!("UISettings isn't available here ({e}); not compared with the saved choice");
assert_eq!(answer, None);
}
}
}
}

View File

@ -27,7 +27,7 @@ fn parse(output: &str) -> f32 {
/// Run `gsettings` and return what it printed, or `None` if it is missing, fails or hangs.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
pub(crate) fn gsettings(args: &[&str]) -> Option<String> {
fn gsettings(args: &[&str]) -> Option<String> {
use std::io::Read;
use std::process::{Command, Stdio};
let mut child = Command::new("gsettings").args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;

View File

@ -20,9 +20,6 @@ environment variable if you set one.
Inter and JetBrains Mono fonts).
2. **Chrome.** Headless Chrome prints the page to PDF with
`--headless=new --no-pdf-header-footer --generate-pdf-document-outline --print-to-pdf`.
It runs with a throwaway `--user-data-dir` and
`--disable-features=MacAppCodeSignClone`, so no code-sign clones are left
behind on macOS.
Skia produces 12 tagged pages with embedded font subsets. Variable
system-ui weights come out as Type 3 fonts and emoji as Type 3 bitmap
glyphs. The pages also get shadings, blend modes, soft masks, tiling

View File

@ -17,9 +17,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.YouseifUS]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@ -107,9 +104,6 @@ sync_github_name = true # use the name on your public GitHub profile as displa
[people.Losminek]
sync_github_name = true
[people.earlvanze]
sync_github_name = true
[people.rp6186030]
real_name = "Raj Patel"
sync_github_name = false
@ -118,14 +112,3 @@ sync_github_name = false
real_name = "Ufuk Efe"
display_name = "Ufuk Efe"
sync_github_name = true
[people.lollinoo]
sync_github_name = true
[people.JeremyYao]
real_name = "Jeremy Yao"
display_name = "Jeremy Yao"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"

View File

@ -14,6 +14,5 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-filters = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true

View File

@ -47,28 +47,18 @@ pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
t
}
/// An indirect structure element and its nearest indirect parent in tree order.
pub(crate) struct Element {
pub object: ObjRef,
pub dict: Dict,
pub page: Option<ObjRef>,
pub parent: Option<ObjRef>,
pub depth: usize,
}
/// Indirect structure elements in document order, with their page and hierarchy.
pub(crate) fn elements(doc: &Document) -> Vec<Element> {
/// Indirect structure elements in document order, with their page.
fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = HashSet::new();
let mut stack: Vec<(Object, Option<ObjRef>, Option<ObjRef>, usize)> =
root.get(b"K").map(|k| vec![(k.clone(), None, None, 0)]).unwrap_or_default();
while let Some((k, pg, parent, depth)) = stack.pop() {
let mut stack: Vec<(Object, Option<ObjRef>)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default();
while let Some((k, pg)) = stack.pop() {
if out.len() > 1_000_000 {
break;
}
match k {
Object::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg, parent, depth))),
Object::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg))),
Object::Ref(r) => {
if !seen.insert(r) {
continue;
@ -77,16 +67,16 @@ pub(crate) fn elements(doc: &Document) -> Vec<Element> {
if d.contains(b"S") {
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg, Some(r), depth.saturating_add(1)));
stack.push((kids.clone(), pg));
}
out.push(Element { object: r, dict: d, page: pg, parent, depth });
out.push((r, d, pg));
}
}
Object::Dict(d) if d.contains(b"S") => {
// Direct elements can't be addressed; their indirect children still can.
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg, parent, depth));
stack.push((kids.clone(), pg));
}
}
_ => {}
@ -228,13 +218,13 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
.into_iter()
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
.filter(|(_, d, _)| d.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|(obj, d, pg)| {
let page = pg.and_then(|p| index.get(&p).copied());
let alt = d.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
Figure { obj: e.object, page, alt, bbox }
Figure { obj, page, alt, bbox }
})
.collect()
}
@ -268,7 +258,7 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
let d = check_figure(doc, obj)?;
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
let pages = pdfcraft_model::pages(doc);
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|(r, _, _)| *r == obj).and_then(|(_, _, p)| p));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
let mut changed = false;

View File

@ -1,282 +0,0 @@
//! Create a valid, reviewable structure tree for PDFs whose painted content is text only.
//!
//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph,
//! which can then be retagged with the existing tag-property tool after human review.
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
const MAX_OPERATORS: usize = 100_000;
const MAX_TEXT_OBJECTS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AutoTagResult {
pub pages: usize,
pub paragraphs: usize,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum AutoTagError {
#[error("the document already has a structure tree; review its existing tags instead")]
AlreadyTagged,
#[error("the document has no text objects to tag")]
NoText,
#[error("the document has too many pages to auto-tag in one edit (maximum {MAX_PAGES})")]
TooManyPages,
#[error("page {page}: {reason}")]
Unsupported { page: usize, reason: String },
#[error("the PDF has no document catalog")]
NoCatalog,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
struct Plan {
page: ObjRef,
ops: Vec<Op>,
spans: Vec<(usize, usize)>,
}
fn unsupported(page: usize, reason: impl Into<String>) -> AutoTagError {
AutoTagError::Unsupported { page: page + 1, reason: reason.into() }
}
fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Vec<u8>, AutoTagError> {
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) };
let resolved = doc.resolve(contents);
let pieces: Vec<Object> = match &*resolved {
Object::Array(items) => items.clone(),
Object::Stream(_) => vec![contents.clone()],
_ => return Err(unsupported(index, "contents are not a stream or array of streams")),
};
let mut all = Vec::new();
for item in pieces {
let value = doc.resolve(&item);
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
if remaining == 0 {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
}
let chain = stream.filters();
let decoded = if chain.is_empty() {
if stream.raw.len() > remaining {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
}
stream.raw.to_vec()
} else {
pdfcraft_filters::decode(&chain, &stream.raw, remaining)
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
all.extend_from_slice(&decoded);
all.push(b'\n');
}
Ok(all)
}
fn harmless_outside(op: &[u8]) -> bool {
matches!(
op,
b"q" | b"Q"
| b"cm"
| b"w"
| b"J"
| b"j"
| b"M"
| b"d"
| b"ri"
| b"i"
| b"gs"
| b"G"
| b"g"
| b"RG"
| b"rg"
| b"K"
| b"k"
| b"CS"
| b"cs"
| b"SC"
| b"sc"
| b"SCN"
| b"scn"
)
}
fn harmless_inside(op: &[u8]) -> bool {
matches!(
op,
b"Tf"
| b"Tc"
| b"Tw"
| b"Tz"
| b"TL"
| b"Tr"
| b"Ts"
| b"Td"
| b"TD"
| b"Tm"
| b"T*"
| b"G"
| b"g"
| b"RG"
| b"rg"
| b"K"
| b"k"
| b"CS"
| b"cs"
| b"SC"
| b"sc"
| b"SCN"
| b"scn"
)
}
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
let root = doc.root().ok_or(AutoTagError::NoCatalog)?;
let catalog = doc.get(root);
let catalog = catalog.as_dict().ok_or(AutoTagError::NoCatalog)?;
if catalog.contains(b"StructTreeRoot") {
return Err(AutoTagError::AlreadyTagged);
}
if catalog.contains(b"AcroForm") {
return Err(unsupported(0, "form fields need their own tags; this text-only action cannot tag them"));
}
let pages = pdfcraft_model::pages(doc);
if pages.len() > MAX_PAGES {
return Err(AutoTagError::TooManyPages);
}
let mut out = Vec::with_capacity(pages.len());
let mut total = 0usize;
for (index, page) in pages.iter().enumerate() {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
}
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
Object::Array(items) => !items.is_empty(),
_ => true,
}) {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let bytes = content_bytes(doc, page, index)?;
let parsed = parse(&bytes);
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
let mut start = None;
let mut has_text = false;
let mut spans = Vec::new();
for (at, op) in parsed.ops.iter().enumerate() {
match op.op.as_slice() {
b"BT" if start.is_none() => {
start = Some(at);
has_text = false;
}
b"ET" => {
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
if has_text {
spans.push((first, at));
if spans.len() > MAX_TEXT_OBJECTS {
return Err(unsupported(index, "too many text objects (maximum 4096)"));
}
}
}
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
name if start.is_some() && harmless_inside(name) => {}
name if start.is_none() && harmless_outside(name) => {}
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
}
}
if start.is_some() {
return Err(unsupported(index, "unterminated BT text object"));
}
total = total.saturating_add(spans.len());
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
}
if total == 0 {
return Err(AutoTagError::NoText);
}
Ok(out)
}
/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree,
/// parent tree and marked-content IDs are written together. Mixed content is refused before any
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
let plans = plan(doc)?;
let mut next = doc.clone();
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
let root = next.add(Object::Null);
let document = next.add(Object::Null);
let mut children = Vec::new();
let mut numbers = Vec::new();
let mut tagged_pages = 0usize;
let mut paragraphs = 0usize;
for page in plans {
if page.spans.is_empty() {
continue;
}
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
let mut parents = Vec::with_capacity(page.spans.len());
let mut starts = std::collections::HashMap::new();
let mut ends = std::collections::HashSet::new();
for (mcid, (first, last)) in page.spans.iter().enumerate() {
let mut element = Dict::new();
element.set(b"Type".to_vec(), Object::name("StructElem"));
element.set(b"S".to_vec(), Object::name("P"));
element.set(b"P".to_vec(), Object::Ref(document));
element.set(b"Pg".to_vec(), Object::Ref(page.page));
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
let reference = next.add(element);
parents.push(Object::Ref(reference));
children.push(Object::Ref(reference));
starts.insert(*first, mcid);
ends.insert(*last);
paragraphs = paragraphs.saturating_add(1);
}
let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2)));
for (at, op) in page.ops.into_iter().enumerate() {
if let Some(mcid) = starts.get(&at) {
let mut properties = Dict::new();
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
}
marked.push(op);
if ends.contains(&at) {
marked.push(Op::new("EMC", Vec::new()));
}
}
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
next.update_dict(page.page, |d| {
d.set(b"Contents".to_vec(), Object::Ref(stream));
d.set(b"StructParents".to_vec(), Object::Int(key));
})?;
numbers.push(Object::Int(key));
numbers.push(Object::Array(parents));
tagged_pages += 1;
}
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(numbers));
let parent_tree = next.add(tree);
let mut doc_element = Dict::new();
doc_element.set(b"Type".to_vec(), Object::name("StructElem"));
doc_element.set(b"S".to_vec(), Object::name("Document"));
doc_element.set(b"P".to_vec(), Object::Ref(root));
doc_element.set(b"K".to_vec(), Object::Array(children));
next.set(document, doc_element);
let mut structure = Dict::new();
structure.set(b"Type".to_vec(), Object::name("StructTreeRoot"));
structure.set(b"K".to_vec(), Object::Ref(document));
structure.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
structure.set(b"ParentTreeNextKey".to_vec(), Object::Int(i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?));
next.set(root, structure);
let mark_info = next.get(catalog).as_dict().and_then(|d| d.get(b"MarkInfo")).map(|m| next.resolve(m)).and_then(|m| m.as_dict().cloned());
let mut mark_info = mark_info.unwrap_or_default();
mark_info.set(b"Marked".to_vec(), Object::Bool(true));
next.update_dict(catalog, |d| {
d.set(b"StructTreeRoot".to_vec(), Object::Ref(root));
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
})?;
*doc = next;
Ok(AutoTagResult { pages: tagged_pages, paragraphs })
}

View File

@ -218,20 +218,6 @@ fn subtype_name(d: &Dict) -> String {
String::from_utf8_lossy(d.name(b"Subtype").unwrap_or(b"Unknown")).into_owned()
}
/// An annotation's rectangle is already in page user space. Ignore missing, malformed and
/// degenerate rectangles rather than sending the checker to an arbitrary place on the page.
fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
let rect = annot.get(b"Rect").map(|r| doc.resolve(r))?;
let values = rect.as_array()?;
let [x0, y0, x1, y1] = values.as_slice() else { return None };
let [x0, y0, x1, y1] = [x0, y0, x1, y1].map(|v| doc.resolve(v).as_f64());
let (x0, y0, x1, y1) = (x0?, y0?, x1?, y1?);
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) || x0 == x1 || y0 == y1 {
return None;
}
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
@ -274,9 +260,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
values.push(("{}", subtype_name(&a)));
"{} annotation on page {p} isn't tagged"
};
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
out.push(Finding::worded(Some(p), pattern, values));
}
}
}
@ -329,13 +313,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
}
let fd = doc.get(field).as_dict().cloned().unwrap_or_default();
if text_of(doc, &fd, b"TU").is_none() {
let mut finding = Finding::worded(
out.push(Finding::worded(
Some(p),
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
));
}
}
}

View File

@ -8,23 +8,19 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::{BTreeSet, HashMap};
use std::collections::BTreeSet;
use pdfcraft_cos::Document;
mod alt;
mod autotag;
mod content;
mod headings;
mod report;
mod structure;
mod tags;
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use headings::{Heading, headings};
pub use report::{REPORT_WORDS, report_html};
pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Category {
@ -426,10 +422,6 @@ impl Status {
pub struct Finding {
/// 0-based page, when the problem is on a page.
pub page: Option<usize>,
/// Indirect structure element that caused the finding, when one is available.
pub object: Option<pdfcraft_cos::ObjRef>,
/// Bounds in PDF user space when the affected content can be located safely.
pub bbox: Option<[f64; 4]>,
pub message: String,
/// The English sentence with a hole, such as `{p}` or `{what}`, where each value went.
pub pattern: &'static str,
@ -440,13 +432,13 @@ pub struct Finding {
impl Finding {
/// A finding whose sentence says the same thing however the document is put together.
pub fn plain(page: Option<usize>, sentence: &'static str) -> Self {
Finding { page, object: None, bbox: None, message: sentence.to_owned(), pattern: sentence, values: Vec::new() }
Finding { page, message: sentence.to_owned(), pattern: sentence, values: Vec::new() }
}
/// A finding built from a wording and what goes in its holes. The English `message` is filled
/// in here, so it and `pattern` can never drift apart.
pub fn worded(page: Option<usize>, pattern: &'static str, values: Vec<(&'static str, String)>) -> Self {
Finding { page, object: None, bbox: None, message: say(pattern, &values), pattern, values }
Finding { page, message: say(pattern, &values), pattern, values }
}
/// A finding that says where the problem is and, in `{what}`, what is wrong, each worded
@ -458,7 +450,7 @@ impl Finding {
pub fn nested(page: Option<usize>, pattern: &'static str, what: &'static str, mut values: Vec<(&'static str, String)>) -> Self {
let message = say(&pattern.replace("{what}", what), &values);
values.push(("{what}", what.to_owned()));
Finding { page, object: None, bbox: None, message, pattern, values }
Finding { page, message, pattern, values }
}
/// This finding as one sentence, said in the language `words` translates into.
@ -620,7 +612,6 @@ pub fn check(doc: &Document, options: &Options) -> Report {
let tagged = structure::has_struct_tree(doc);
let scan = content::Scan::run(doc, &pages, &page_list);
let mut tree_rules = structure::run(doc, &pages, &options.rules);
let mut figure_bounds: Option<HashMap<pdfcraft_cos::ObjRef, (usize, [f64; 4])>> = None;
let mut results = Vec::with_capacity(32);
for rule in Rule::ALL {
if !options.rules.contains(&rule) {
@ -645,24 +636,6 @@ pub fn check(doc: &Document, options: &Options) -> Report {
}
_ => tree_rules.remove(&rule).unwrap_or_default(),
};
if rule.category() == Category::AlternateText && findings.iter().any(|f| f.object.is_some()) {
let bounds = figure_bounds.get_or_insert_with(|| {
figures(doc)
.into_iter()
.filter_map(|figure| {
let (page, bbox) = (figure.page?, figure.bbox?);
(bbox.iter().all(|v| v.is_finite()) && bbox[0] < bbox[2] && bbox[1] < bbox[3]).then_some((figure.obj, (page, bbox)))
})
.collect()
});
for finding in &mut findings {
if let Some((page, bbox)) = finding.object.and_then(|obj| bounds.get(&obj))
&& finding.page == Some(*page)
{
finding.bbox = Some(*bbox);
}
}
}
if total > MAX_FINDINGS {
let more = total - MAX_FINDINGS;
findings.truncate(MAX_FINDINGS);

View File

@ -48,10 +48,6 @@ pub fn report_html(report: &Report, file_name: &str, date: &str, lang: &str, wor
// `REPORT_WORDS` can be read off this file.
h.push_str(&if report.count(Status::Failed) > 0 {
esc(&words("The checker found problems which may prevent the document from being fully accessible."))
} else if report.count(Status::Manual) > 0 {
format!("{}: {}", esc(&words(Status::Manual.label())), report.count(Status::Manual))
} else if report.count(Status::Skipped) > 0 {
format!("{}: {}", esc(&words(Status::Skipped.label())), report.count(Status::Skipped))
} else {
esc(&words("The checker found no problems in this document."))
});

View File

@ -154,8 +154,6 @@ fn kind_of(ty: &[u8]) -> Kind {
/// is filled in as the walk goes on.
#[derive(Debug)]
pub(crate) struct Frame {
/// Indirect object holding this tag, if it is not an inline dictionary.
object: Option<ObjRef>,
/// The standard type after the role map, or the type as written when it maps to nothing.
ty: Vec<u8>,
kind: Kind,
@ -316,7 +314,7 @@ impl Walker<'_> {
}
Object::Int(_) => self.mark_content(),
Object::Ref(r) => self.reference(*r, depth, page, v),
Object::Dict(_) => self.dict(loc, depth, page, None, v),
Object::Dict(_) => self.dict(loc, depth, page, v),
_ => {}
}
}
@ -328,13 +326,13 @@ impl Walker<'_> {
}
let target = self.doc.get(r);
if target.as_dict().is_some() {
self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, Some(r), v);
self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, v);
}
}
/// A dictionary. Marked content and object references only mark the open element; a
/// dictionary with /S is an element, and its /K kids are read inside it.
fn dict<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, object: Option<ObjRef>, v: &mut V) {
fn dict<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, v: &mut V) {
let Some(d) = loc.value().and_then(|o| o.as_dict()) else { return };
match d.name(b"Type") {
Some(b"MCR") => return self.mark_content(),
@ -358,7 +356,6 @@ impl Walker<'_> {
let kind = kind_of(&ty);
let attrs = if matches!(kind, Kind::Table | Kind::Td | Kind::Th) { self.attr_dicts(d) } else { Vec::new() };
let frame = Frame {
object,
ty,
kind,
index: self.opened,
@ -466,15 +463,13 @@ fn row_owner(path: &[Frame]) -> Option<usize> {
fn worded(e: &Frame, what: &'static str, extra: Vec<(&'static str, String)>) -> Finding {
let mut values = extra;
values.push(("{ty}", String::from_utf8_lossy(&e.ty).into_owned()));
let mut finding = match e.page {
match e.page {
Some(p) => {
values.push(("{p}", p.saturating_add(1).to_string()));
Finding::nested(e.page, "{ty} element on page {p}: {what}", what, values)
}
None => Finding::nested(None, "{ty} element: {what}", what, values),
};
finding.object = e.object;
finding
}
}
/// A finding whose `{what}` wording has no holes of its own.

View File

@ -1,166 +0,0 @@
//! Properties of reachable indirect structure elements.
use pdfcraft_cos::{CosError, Document, ObjRef, Object, PdfString};
use crate::alt;
/// A tag that can be addressed by an edit. Inline dictionaries have no stable object reference.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TagInfo {
pub object: ObjRef,
/// Nearest reachable indirect parent, if any. Inline ancestors are skipped.
pub parent: Option<ObjRef>,
/// Indentation level among reachable indirect tags.
pub depth: usize,
pub kind: String,
pub title: Option<String>,
pub actual_text: Option<String>,
pub alt: Option<String>,
pub language: Option<String>,
/// Zero-based page when `/Pg` is known.
pub page: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagProperty {
Type,
Title,
ActualText,
AlternateText,
Language,
}
/// Where to place a structure tag relative to one of its direct siblings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagPlacement {
Before,
After,
}
impl TagProperty {
pub fn from_id(id: &str) -> Option<Self> {
match id {
"type" => Some(Self::Type),
"title" => Some(Self::Title),
"actual_text" => Some(Self::ActualText),
"alternate_text" => Some(Self::AlternateText),
"language" => Some(Self::Language),
_ => None,
}
}
fn key(self) -> &'static [u8] {
match self {
Self::Type => b"S",
Self::Title => b"T",
Self::ActualText => b"ActualText",
Self::AlternateText => b"Alt",
Self::Language => b"Lang",
}
}
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum TagError {
#[error("object {0} is not a reachable indirect structure tag")]
NotATag(ObjRef),
#[error("a tag type must be a nonempty PDF name using letters, digits, colon, period, underscore or hyphen")]
InvalidType,
#[error("tag property text is too long (maximum 65536 bytes)")]
TooLong,
#[error("both tags must be distinct, reachable, direct children of the same structure parent")]
NotSiblings,
#[error("the parent must list each tag exactly once in its /K array")]
AmbiguousChildren,
#[error("the tag is already at the requested position")]
AlreadyThere,
#[error(transparent)]
Cos(#[from] CosError),
}
/// Reorder two direct sibling tags in their parent's `/K` array. Content and page appearance
/// stay untouched; tagged reading order follows the new array order.
pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: TagPlacement) -> Result<(), TagError> {
if object == anchor {
return Err(TagError::NotSiblings);
}
let reachable = alt::elements(doc);
if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) {
return Err(TagError::NotSiblings);
}
let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref));
let Some(parent) = parent_of(object) else { return Err(TagError::NotSiblings) };
if parent_of(anchor) != Some(parent) {
return Err(TagError::NotSiblings);
}
let owner = doc.get(parent);
let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else {
return Err(TagError::AmbiguousChildren);
};
let one_position = |child| {
let mut matches = children.iter().enumerate().filter(|(_, item)| item.as_ref() == Some(child));
matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index))
};
let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) };
let mut reordered = children.clone();
let moved = reordered.remove(from);
let target = if from < to { to - 1 } else { to };
let destination = target + usize::from(placement == TagPlacement::After);
if destination == from {
return Err(TagError::AlreadyThere);
}
reordered.insert(destination, moved);
doc.update_dict(parent, |d| d.set(b"K".to_vec(), Object::Array(reordered)))?;
Ok(())
}
fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text))
}
/// List reachable, indirect tags in structure-tree order.
pub fn tags(doc: &Document) -> Vec<TagInfo> {
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
alt::elements(doc)
.into_iter()
.filter_map(|e| {
Some(TagInfo {
object: e.object,
parent: e.parent,
depth: e.depth,
kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(),
title: text(doc, &e.dict, b"T"),
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
language: text(doc, &e.dict, b"Lang"),
page: e.page.and_then(|p| page_index.get(&p).copied()),
})
})
.collect()
}
/// Change one property of a reachable tag; `None` removes a text property.
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
if !alt::elements(doc).iter().any(|e| e.object == object) {
return Err(TagError::NotATag(object));
}
// ActualText may intentionally contain leading or trailing whitespace; preserve it.
let value = value.filter(|s| !s.is_empty());
if value.is_some_and(|s| s.len() > 65_536) {
return Err(TagError::TooLong);
}
if property == TagProperty::Type
&& !value.is_some_and(|s| s.len() <= 127 && s.as_bytes().iter().all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'.' | b'_' | b'-')))
{
return Err(TagError::InvalidType);
}
doc.update_dict(object, |d| match value {
Some(s) if property == TagProperty::Type => d.set(property.key().to_vec(), Object::name(s)),
Some(s) => d.set(property.key().to_vec(), Object::String(PdfString::text(s))),
None => {
d.remove(property.key());
}
})?;
Ok(())
}

View File

@ -1,6 +1,6 @@
use std::sync::Arc;
use pdfcraft_cos::{Document, Object};
use pdfcraft_cos::Document;
use super::*;
@ -35,67 +35,6 @@ fn failed(r: &Report) -> Vec<Rule> {
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
#[test]
fn autotag_text_writes_marked_content_and_parent_tree_across_pages() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 18 Tf 20 160 Td (Heading) Tj ET BT /F1 12 Tf 20 120 Td (Body) Tj ET"),
FONT.into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 7 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 120 Td (Second page) Tj ET"),
],
"",
);
let result = autotag_text(&mut doc).unwrap();
assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 3 });
let catalog = doc.get(doc.root().unwrap());
let root = catalog.as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
let tree = doc.get(root);
let parent_tree = tree.as_dict().unwrap().reference(b"ParentTree").unwrap();
let numbers = doc.get(parent_tree);
let entries = numbers.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].as_int(), Some(0));
assert_eq!(entries[2].as_int(), Some(1));
assert_eq!(entries[1].as_array().unwrap().len(), 2);
assert_eq!(entries[3].as_array().unwrap().len(), 1);
for page in pdfcraft_model::pages(&doc) {
let content = page.dict.get(b"Contents").unwrap();
let stream = doc.resolve(content);
let Object::Stream(stream) = &*stream else { panic!("marked page content") };
let text = String::from_utf8(stream.decoded_strict().unwrap()).unwrap();
assert!(text.contains("/P <</MCID 0>> BDC"), "{text}");
assert!(text.contains("EMC"), "{text}");
}
let report = check(&doc, &Options::default());
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
assert_eq!(status(&report, Rule::TaggedContent), Status::Passed);
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(tags(&reopened).len(), 4);
assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed);
}
#[test]
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"),
FONT.into(),
],
"",
);
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
}
#[test]
fn an_untagged_document_fails_the_document_rules() {
let doc = pdf(
@ -225,48 +164,6 @@ fn structure_doc(objects: &[String]) -> Document {
pdf(&objs, "")
}
#[test]
fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() {
let mut doc =
structure_doc(&["<< /S /Document /K [4 0 R] >>".into(), "<< /S /Figure /T (Old) /Alt (A chart) >>".into(), "<< /S /Figure >>".into()]);
let tag = pdfcraft_cos::ObjRef::new(4, 0);
let listed = tags(&doc);
assert_eq!(listed.len(), 2);
assert_eq!(listed[1].object, tag);
assert_eq!(listed[1].title.as_deref(), Some("Old"));
for (property, value) in [
(TagProperty::Type, "P"),
(TagProperty::Title, "Figure title"),
(TagProperty::ActualText, " Revenue increased "),
(TagProperty::AlternateText, "A rising chart"),
(TagProperty::Language, "en-GB"),
] {
set_tag_property(&mut doc, tag, property, Some(value)).unwrap();
}
let edited = &tags(&doc)[1];
assert_eq!(edited.kind, "P");
assert_eq!(edited.title.as_deref(), Some("Figure title"));
assert_eq!(edited.actual_text.as_deref(), Some(" Revenue increased "));
assert_eq!(edited.alt.as_deref(), Some("A rising chart"));
assert_eq!(edited.language.as_deref(), Some("en-GB"));
set_tag_property(&mut doc, tag, TagProperty::Title, None).unwrap();
assert_eq!(tags(&doc)[1].title, None);
assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, None), Err(TagError::InvalidType)));
assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, Some("bad type")), Err(TagError::InvalidType)));
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(5, 0), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
}
#[test]
fn tag_hierarchy_keeps_indirect_parent_through_inline_tags() {
let doc = structure_doc(&["<< /S /Document /K 4 0 R >>".into(), "<< /S /Sect /K << /S /Div /K 5 0 R >> >>".into(), "<< /S /Figure >>".into()]);
let listed = tags(&doc);
assert_eq!(
listed.iter().map(|t| (t.object.num, t.parent.map(|p| p.num), t.depth)).collect::<Vec<_>>(),
[(3, None, 0), (4, Some(3), 1), (5, Some(4), 2)]
);
}
fn checked(doc: &Document, rules: &[Rule]) -> Report {
check(doc, &Options { rules: rules.iter().copied().collect(), pages: None })
}
@ -319,16 +216,6 @@ fn cycles_shared_kids_and_missing_kids_are_read_once() {
assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
}
#[test]
fn structure_findings_identify_indirect_elements_without_guessing_inline_objects() {
let doc = structure_doc(&["<< /S /Document /K [4 0 R << /S /Figure >>] >>".into(), "<< /S /Figure >>".into()]);
let r = checked(&doc, &[Rule::FiguresAltText]);
let found = &r.result(Rule::FiguresAltText).unwrap().findings;
assert_eq!(found.len(), 2);
assert_eq!(found[0].object.map(|r| r.num), Some(4));
assert_eq!(found[1].object, None);
}
#[test]
fn nesting_is_read_to_the_depth_limit_and_no_further() {
// Sections, each the kid of the one before, ending in a figure without alternate text. The
@ -413,19 +300,6 @@ fn page_content_and_annotation_problems_are_found() {
assert_eq!(got, [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TabOrder, Rule::FieldDescriptions]);
assert_eq!(r.result(Rule::TaggedContent).unwrap().findings[0].message, "Page 1: 1 untagged content item");
assert!(r.result(Rule::FieldDescriptions).unwrap().findings[0].message.contains("\"name\""));
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, Some([20.0, 20.0, 80.0, 40.0]));
assert_eq!(r.result(Rule::FieldDescriptions).unwrap().findings[0].bbox, Some([20.0, 60.0, 120.0, 80.0]));
}
#[test]
fn annotation_findings_only_locate_valid_rectangles() {
let cases = [("[80 40 20 20]", Some([20.0, 20.0, 80.0, 40.0])), ("[20 20 20 40]", None), ("[20 20 80]", None), ("[20 20 80 (bad)]", None)];
for (rect, expected) in cases {
let mut objs = good();
objs[8] = format!("<< /Type /Annot /Subtype /Link /Rect {rect} >>");
let r = checked(&pdf(&objs, "/Info 8 0 R"), &[Rule::TaggedAnnotations]);
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, expected, "{rect}");
}
}
#[test]
@ -482,13 +356,6 @@ fn rules_have_ids_and_the_report_lists_them() {
let other = report_html(&report, "a<b>.pdf", "2026-10-02", "es", &|w| format!("«{w}»"));
assert!(other.contains("<html lang=\"es\">") && other.contains("<h3>«Headings»</h3>") && other.contains("a&lt;b&gt;.pdf"));
assert!(!other.contains(">Headings<"), "an English heading is left in the report: {other}");
let manual_only = Report { results: vec![RuleResult { rule: Rule::LogicalReadingOrder, status: Status::Manual, findings: Vec::new() }] };
let manual_html = report_html(&manual_only, "review.pdf", "2026-10-02", "en", &in_english);
assert!(manual_html.contains("<p>Needs manual check: 1</p>"));
assert!(!manual_html.contains("no problems"));
let all_passed = Report { results: vec![RuleResult { rule: Rule::LogicalReadingOrder, status: Status::Passed, findings: Vec::new() }] };
let passed_html = report_html(&all_passed, "review.pdf", "2026-10-02", "en", &in_english);
assert!(passed_html.contains("<p>The checker found no problems in this document.</p>"), "{passed_html}");
}
fn figures_doc() -> Document {
@ -521,49 +388,6 @@ fn figures_are_listed_with_their_alt_text_and_place() {
assert_eq!(f.len(), 2, "role-mapped Photo counts");
assert_eq!((f[0].page, f[0].alt.as_deref(), f[0].bbox), (Some(0), None, Some([10.0, 150.0, 50.0, 170.0])));
assert_eq!((f[1].alt.as_deref(), f[1].bbox), (Some("A bar"), Some([100.0, 100.0, 150.0, 130.0])));
let report = check(&doc, &Options { rules: [Rule::FiguresAltText].into(), pages: None });
let finding = &report.result(Rule::FiguresAltText).unwrap().findings[0];
assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox));
}
#[test]
fn sibling_tags_move_in_reading_order_without_changing_content() {
let mut doc = figures_doc();
let before: Vec<_> = tags(&doc).into_iter().map(|tag| tag.object.num).collect();
assert_eq!(before, [6, 7, 8]);
let content = doc.get(pdfcraft_cos::ObjRef::new(4, 0));
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before).unwrap();
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 8, 7]);
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(4, 0)), content, "page content stays unchanged");
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before),
Err(TagError::AlreadyThere)
);
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After).unwrap();
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 7, 8]);
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(6, 0), TagPlacement::Before),
Err(TagError::NotSiblings)
);
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 1), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before),
Err(TagError::NotSiblings)
);
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
d.set(
b"K".to_vec(),
pdfcraft_cos::Object::Array(vec![
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)),
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)),
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)),
]),
);
})
.unwrap();
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After),
Err(TagError::AmbiguousChildren)
);
}
#[test]

View File

@ -47,10 +47,10 @@ deterministic; the engine supplies them.
never paint a second icon.
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
viewers scale that appearance into the new rectangle. Locked stamps refuse the edit.
- Picture stamps (a natural-size or explicit-rectangle image, a PDF page, or an image
signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so
the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps
that appearance matrix. The rotation is the page's `/Rotate` at placement.
- Natural-size image stamps placed by the engine counterrotate their appearance on rotated
pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures
and PDF-page stamps still use their existing placement behavior; page-rotation support for
those paths is not complete.
## Not yet

View File

@ -264,11 +264,6 @@ pub(crate) fn picture_turn(d: &Dict) -> i64 {
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The page `/Rotate` a rubber stamp's label is turned back by (`/PCStampRotate`).
fn stamp_turn(d: &Dict) -> i64 {
d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
@ -637,26 +632,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
let kind = crate::StampKind::from_name(other)?;
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
let turn = stamp_turn(d);
let draw = if turn == 0 {
rect
} else {
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
if dw < 1.0 || dh < 1.0 {
return None;
}
[0.0, 0.0, dw, dh]
};
let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res);
if turn != 0 {
// Upright in the stamp's displayed box, then mapped into `/Rect` the way
// the page maps display space into user space. Viewers fit that box to
// `/Rect`; the page rotation then shows the label the right way up.
let m = pdfcraft_model::view_matrix_for(turn, rect);
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
}
return Some(stream);
return Some(stamp(kind, rect, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res));
}
}
}

View File

@ -26,8 +26,7 @@ mod tests;
pub enum AnnotError {
#[error("the document has no page tree")]
NoPageTree,
/// Holds the 0-based index; the message names the 1-based page a user counts (#1550).
#[error("page {} does not exist", .0.saturating_add(1))]
#[error("page {0} does not exist")]
NoSuchPage(usize),
#[error("there is no comment {index} on page {page}")]
NoSuchAnnotation { page: usize, index: usize },
@ -1091,11 +1090,6 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
if let Some(b) = by {
d.set(b"PCByLine".to_vec(), PdfString::text(b));
}
// The label is drawn for the page as displayed. A later page rotation is a new edit.
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
}
}
Shape::TypedSignature { contours, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
@ -1242,9 +1236,8 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
Ok(index)
}
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
/// box is fitted to `/Rect` by PDF viewers.
/// Counterrotate a newly placed image stamp's appearance into displayed-page axes.
/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers.
pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> {
let matrix = match rotation {
90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0],
@ -1254,8 +1247,8 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
};
let (_, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() {
return Err(AnnotError::Invalid("only a picture stamp can be oriented".into()));
if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
return Err(AnnotError::Invalid("only an image stamp can be oriented".into()));
}
let normal = d
.get(b"AP")
@ -1270,21 +1263,6 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
Ok(())
}
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> {
let drawn_elsewhere =
d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none();
if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) {
return Err(AnnotError::Undrawable(format!(
"PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)",
c.escape_debug(),
u32::from(c)
)));
}
Ok(())
}
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its
/// text (a restyle, a new size or colour).
///
@ -1293,17 +1271,17 @@ fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> {
/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?`
/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn.
pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
refuse_foreign_text(doc, &annot_dict(doc, r))?;
draw_appearance(doc, r)
}
/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style.
fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> {
if let Err(e) = set_appearance(doc, r) {
doc.set(r, Object::Dict(before));
return Err(e);
let d = annot_dict(doc, r);
let drawn_elsewhere =
d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(&d).is_none();
if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(&d) {
return Err(AnnotError::Undrawable(format!(
"PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)",
c.escape_debug(),
u32::from(c)
)));
}
Ok(())
draw_appearance(doc, r)
}
/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard
@ -1322,8 +1300,8 @@ fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
let mut stream = appearance::build(d)?;
// Picture-stamp restyling must retain the placement's page-axis correction.
if d.get(b"PCPicture").is_some() {
// Image stamp restyling must retain the placement's page-axis correction.
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
let matrix = d.get(b"AP").and_then(|ap| {
let ap = doc.resolve(ap);
let normal = doc.resolve(ap.as_dict()?.get(b"N")?);
@ -1804,13 +1782,13 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect()
})
.collect();
let before = annot_dict(doc, r);
doc.update_dict(r, |d| {
d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
set_appearance(doc, r)?;
Ok(())
}
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
@ -1822,9 +1800,6 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
let d = annot_dict(doc, r);
if !new_text {
refuse_foreign_text(doc, &d)?;
}
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
@ -1850,16 +1825,7 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
}
touch(d, meta);
})?;
let before = d;
if new_text {
if let Err(e) = draw_appearance(doc, r) {
doc.set(r, Object::Dict(before));
return Err(e);
}
Ok(())
} else {
redraw_or_restore(doc, r, before)
}
if new_text { draw_appearance(doc, r) } else { set_appearance(doc, r) }
}
fn border_width_of(d: &Dict) -> f64 {
@ -1896,9 +1862,7 @@ pub fn set_style(
return Err(AnnotError::Invalid("this comment has no line endings to change".into()));
}
}
refuse_foreign_text(doc, &d)?;
let free_text = subtype == "FreeText";
let before = d;
doc.update_dict(r, |d| {
if let Some(c) = color {
if free_text {
@ -1936,7 +1900,7 @@ pub fn set_style(
ensure_ending_room(d);
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
set_appearance(doc, r)
}
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
@ -1962,8 +1926,6 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
if fill.is_some_and(|c| !finite(&c)) {
return Err(AnnotError::Invalid("invalid fill colour".into()));
}
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
match fill {
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
@ -1973,7 +1935,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
}
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
set_appearance(doc, r)
}
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.

View File

@ -187,7 +187,6 @@ fn invalid_geometry_is_rejected() {
assert!(matches!(add_annotation(&mut doc, &new(0, s.clone()), &meta("x")), Err(AnnotError::Invalid(_))), "{s:?}");
}
assert_eq!(add_annotation(&mut doc, &new(5, Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }), &meta("x")), Err(AnnotError::NoSuchPage(5)));
assert_eq!(AnnotError::NoSuchPage(5).to_string(), "page 6 does not exist", "the message counts pages from 1 (#1550)");
assert!(!doc.is_modified(), "failed adds change nothing");
}
@ -1220,13 +1219,9 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
on_page.push(Object::Ref(r));
set_annots(&mut doc, page, on_page).unwrap();
let k = list(&doc, 0).len() - 1;
let before = list(&doc, 0)[k].clone();
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err();
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}");
assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance");
let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(_)), "{err:?}");
assert_eq!(list(&doc, 0)[k], before, "a refused resize keeps the rectangle");
assert_eq!(list(&doc, 0)[k].get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")), Some(drawn), "its appearance is kept");
// New text from the user is taken (and marked), as for any comment.
set_contents(&mut doc, 0, k, "欢迎光临", &meta("")).unwrap();
assert_eq!(mark(&doc, k), Some('欢'));

View File

@ -18,9 +18,8 @@ shading and highlights don't either: they're painted before the text, or light a
Limits, by design and documented rather than hidden:
- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose
corners form an axis-aligned rectangle (not other paths, shadings or patterns), and a
translucent one (`/ca` below 1) is not flagged;
- only `re` rectangles closed by a fill count as covers (not arbitrary paths, shadings or
patterns), and a translucent one (`/ca` below 1) is not flagged;
- covers inside form XObjects are found, but only text drawn directly in the page content is
checked under them;
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign.

View File

@ -1,11 +1,10 @@
//! Walk page content streams and collect the opaque filled rectangles they draw.
//!
//! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque
//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath
//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts
//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`),
//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading
//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is
//! color (`re` + `f`) is the classic way a "redaction" is faked. Graphics state tracked here is
//! deliberately small: the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and
//! the non-stroking alpha from ExtGState (`gs` reading `/ca`). Form XObjects are followed
//! read-only with a depth cap. Anything fancier (arbitrary filled paths, shadings, patterns) is
//! out of scope and documented as such.
//!
//! Paint order: a page's content streams are one stream in pieces (ISO 32000-2 §7.8.2), so they
@ -46,58 +45,6 @@ struct Gs {
alpha: f64,
}
/// The path under construction, as far as the audit cares about it.
enum Path {
Empty,
/// A `re` rectangle, in user space.
Rect([f64; 4]),
/// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`.
Lines(Vec<[f64; 2]>, bool),
/// Anything else: curves, a second subpath, a CTM change mid-path.
Other,
}
impl Path {
/// The rectangle a fill of this path paints, if the path is one.
fn rect(self) -> Option<[f64; 4]> {
match self {
Path::Rect(r) => Some(r),
Path::Lines(pts, _) => lines_rect(pts),
Path::Empty | Path::Other => None,
}
}
}
/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated
/// start point is dropped first; the remaining four corners must alternate horizontal and
/// vertical edges.
fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> {
// CTM products leave rounding noise, so corners compare with a tolerance (in points).
const EPS: f64 = 1e-6;
let same = |a: f64, b: f64| (a - b).abs() <= EPS;
if pts.len() == 5 && same(pts[0][0], pts[4][0]) && same(pts[0][1], pts[4][1]) {
pts.pop();
}
let [a, b, c, d] = <[[f64; 2]; 4]>::try_from(pts).ok()?;
let fits = |first_horizontal: bool| {
[(a, b), (b, c), (c, d), (d, a)]
.iter()
.enumerate()
.all(|(i, (p, q))| if (i % 2 == 0) == first_horizontal { same(p[1], q[1]) } else { same(p[0], q[0]) })
};
if !(fits(true) || fits(false)) {
return None;
}
let (xs, ys) = ([a[0], b[0], c[0], d[0]], [a[1], b[1], c[1], d[1]]);
let r = [
xs.into_iter().fold(f64::INFINITY, f64::min),
ys.into_iter().fold(f64::INFINITY, f64::min),
xs.into_iter().fold(f64::NEG_INFINITY, f64::max),
ys.into_iter().fold(f64::NEG_INFINITY, f64::max),
];
(r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r)
}
impl Default for Gs {
fn default() -> Self {
Gs { ctm: Matrix::IDENTITY, fill: [0.0, 0.0, 0.0], alpha: 1.0 }
@ -132,7 +79,8 @@ impl<'a> Walker<'a> {
depth: usize,
at_do: Option<(usize, usize)>,
) -> Result<(), AuditError> {
let mut path = Path::Empty;
// A pending `re` rectangle, in the user space active when it was built.
let mut pending: Option<[f64; 4]> = None;
for (i, op) in ops.iter().enumerate() {
self.bump()?;
// Paint position of this operator: its own `(piece, joined index)` at page level,
@ -147,20 +95,15 @@ impl<'a> Walker<'a> {
if let Some(g) = stack.pop() {
*gs = g;
}
path = Path::Empty;
pending = None;
}
b"cm" => {
let before = gs.ctm;
if let Some(m) = Matrix::from_operands(&op.operands)
&& m.0.iter().all(|v| v.is_finite())
{
gs.ctm = m.then(&gs.ctm);
}
// A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the
// pending path, so keep it for the fill (#1333).
if gs.ctm != before && !matches!(path, Path::Empty) {
path = Path::Other;
}
pending = None;
}
b"g" => {
if let Some(v) = op.num(0).filter(|v| v.is_finite()) {
@ -190,7 +133,7 @@ impl<'a> Walker<'a> {
}
}
b"re" => {
let rect = op.nums::<4>().and_then(|[x, y, w, h]| {
pending = op.nums::<4>().and_then(|[x, y, w, h]| {
if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 {
let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]);
(r[2] > r[0] && r[3] > r[1]).then_some(r)
@ -198,39 +141,18 @@ impl<'a> Walker<'a> {
None
}
});
path = rect.map_or(Path::Other, Path::Rect);
}
b"m" => {
path = match (path, user_point(gs, op)) {
(Path::Empty, Some(p)) => Path::Lines(vec![p], false),
_ => Path::Other,
};
}
b"l" => {
path = match (path, user_point(gs, op)) {
// Past a repeated start point it can no longer be a rectangle.
(Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => {
pts.push(p);
Path::Lines(pts, false)
}
_ => Path::Other,
};
}
b"h" => {
path = match path {
Path::Lines(pts, _) => Path::Lines(pts, true),
_ => Path::Other,
};
}
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect()
if let Some(r) = pending.take()
&& gs.alpha >= 1.0
{
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
}
// Any other path construction clears the pending rectangle.
}
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"S" | b"s" | b"n" | b"W" | b"W*" => {
pending = None;
}
b"S" | b"s" | b"n" => path = Path::Empty,
b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other,
b"Do" => {
if let Some(name) = op.name(0) {
if depth >= MAX_DEPTH {
@ -238,7 +160,7 @@ impl<'a> Walker<'a> {
}
self.walk_xobject(name, gs, resources, depth, pos)?;
}
path = Path::Empty;
pending = None;
}
_ => {}
}
@ -279,13 +201,6 @@ impl<'a> Walker<'a> {
}
}
/// The `x y` operands of `m`/`l`, mapped to user space.
fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> {
let [x, y] = op.nums::<2>().filter(|v| v.iter().all(|n| n.is_finite()))?;
let (x, y) = gs.ctm.apply(x, y);
Some([x, y])
}
/// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque.
fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
let alpha = resources

View File

@ -113,20 +113,6 @@ fn faux_redaction_is_found() {
assert!(f.covered_text.contains("SECRET"), "covered_text was {:?}", f.covered_text);
}
#[test]
fn identity_matrix_between_rectangle_and_fill_still_covers_text() {
// A no-op `cm` inside the path must not drop the pending rectangle (#1333).
for cover in [&b"0 g\n100 688 220 32 re 1 0 0 1 0 0 cm f\n"[..], b"0 g\n1 0 0 1 0 0 cm 100 688 220 32 re f\n"] {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
}
#[test]
fn clean_document_has_no_findings() {
let content = b"BT /F1 24 Tf 100 700 Td (nothing hidden here) Tj ET\n";
@ -227,48 +213,3 @@ fn self_drawing_form_fails_the_audit() {
let result = audit_redactions(&open(b.build()));
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
}
#[test]
fn rectangle_drawn_as_a_path_covers_text_like_re() {
// The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or
// only by the fill itself), or in a scaled user space: all are faux redactions (#1510).
let covers: [&[u8]; 5] = [
b"0 g\n100 688 220 32 re f\n",
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n",
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n",
b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n",
b"0 g\nq 2 0 0 2 0 0 cm 50 344 m 160 344 l 160 360 l 50 360 l h f Q\n",
];
for cover in covers {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
}
#[test]
fn non_rectangular_path_is_not_a_cover() {
// Each path's bounding box covers the text, but the shape itself does not hide it all.
let covers: [&[u8]; 5] = [
// A triangle.
b"0 g\n100 688 m 320 688 l 320 720 l h f\n",
// A triangle, then a line from its start point after `h`: not a fourth corner.
b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n",
// A trapezoid: four corners, one slanted side.
b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l h f\n",
// A rectangle rotated 5 degrees about the text by the CTM.
b"0 g\nq 0.9962 0.0872 -0.0872 0.9962 62.15 -15.63 cm 100 688 m 320 688 l 320 720 l 100 720 l h f Q\n",
// Two subpaths filled together: even-odd leaves the inner one as a hole.
b"0 g\n90 680 m 330 680 l 330 730 l 90 730 l h 100 688 m 320 688 l 320 720 l 100 720 l h f*\n",
];
for cover in covers {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover));
}
}

View File

@ -10,13 +10,10 @@ workspace = true
[dependencies]
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-organize = { workspace = true }
pdfcraft-platform = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
tempfile = "3.27"
png = "0.18"
base64 = { version = "0.22", optional = true }
@ -28,7 +25,7 @@ mcp = ["dep:base64"]
[dev-dependencies]
pdfcraft-fonts = { workspace = true }
flate2.workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg"] }

View File

@ -25,38 +25,9 @@ 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. 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.
- `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.
- 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
Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned
text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector
capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the
renderer result instead of copying glyphs. Oversized pages remain usable by the current call but
are not cached; evicted pages can be extracted again. A changed document version invalidates its
cached text on the next text request, and closing a document removes its entries. Cache entries
do not keep an old PDF byte buffer alive.
This bounds retained text-cache data, not total process memory: active extraction batches,
renderers, document buffers and the requested output have separate lifetimes. Search still reads
growing batches (8 to 128 pages), with one lookahead match to determine continuation.
`text_find` keeps matches from readable pages when another reached page fails extraction. It
returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and
`warnings_truncated`. At most 100 warnings and 1,024 UTF-8 bytes per error are returned. Failures
are retried on later calls; they are not cached as empty pages. Warnings describe pages visited in
search order, up to the continuation point, not failures in unvisited later pages.
`complete: true` means the requested search was exhausted with no page failures and no further
matches. `next: {start_page, skip}` exists only when another match would exceed `limit`. A search
that reaches the end with warnings therefore has `complete: false` **without** `next`. Callers
should follow `next` when present and separately inspect warnings. A capped search can return
both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged.
`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed
semantics; they never silently act on partial text.
## Shared field properties
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
@ -114,15 +85,6 @@ moves 1–1000 references atomically in displayed points (right/down), preservin
and fonts. A refusal changes nothing; success is one undo step. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Accessibility review record
Open the original and edited PDFs as separate documents, then call
`accessibility_review_record {before, after, path}`. The tool writes a JSON record atomically within
`--root`: SHA-256 hashes of both working PDFs, rule statuses and finding counts before and after,
changed rules, and rules still failed or requiring manual review. `all: true` also includes the
colour-contrast rule. It does not change either PDF or certify accessibility. Keep the original open
while making edits to the second document so the comparison has a stable baseline.
## Common command tools
Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its
@ -147,40 +109,3 @@ Exports complete synchronously. `progressToken` and cancellation notifications a
ignored; no background-job or cancellation behavior is added.
Session resources `pdfcraft://document` and `pdfcraft://commands`, and the refusal of unknown argument keys, are described in [docs/mcp.md](../../docs/mcp.md).
For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based index from
`text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the
working document unchanged.
## Existing text and document fonts
`text_font_files` discovers eligible installed files on request (or specified confined folders),
`text_font_import` registers a full document face, and `text_fonts` lists source/full resources.
Use `text_edit.font_resource` to choose a resource, or keep source faces and let matching full
faces complete absent subset glyphs. `text_edit.ranges` accepts disjoint UTF-8 byte ranges;
`text_edit_layout` measures the same resource/range draft without committing. All changes are
undoable and survive saving/reopening. `image_hit` shares the UI's curve and hole hit testing.
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
and [image editing](../../docs/editing-images.md).
## Opening local GoToR links
`link_open {doc, page, index, password?}` follows a local PDF link using `link_list`
numbering. Relative file specifications open normally. An **absolute** file specification
requires `allow_absolute: true`, after reviewing the target reported by `link_list`.
Omitting the argument or passing false refuses the request before checking or reading the
target file. This is an intentional compatibility change for callers that previously opened
absolute GoToR targets without explicit consent. Opting in does not bypass `--root`, symlink
confinement, regular-file, PDF-extension, size, or password validation. Consent applies only
to that call, including each password retry; it is never remembered for other links.
The desktop UI asks Allow/Cancel before following absolute GoToR targets and displays the
complete path. Relative links retain their existing behavior. Named destinations remain
unapplied with a warning.
## Folder batches
`doc_batch` runs conversion, OCR and compression over files and optionally recursive
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.

View File

@ -1,10 +1,7 @@
//! Accessibility tools: the full check, the accessibility report, and the automatic fixes.
use pdfcraft_engine::DocId;
use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
@ -34,19 +31,7 @@ pub(crate) fn report_json(r: &Report) -> Value {
.results
.iter()
.map(|x| {
let findings: Vec<Value> = x
.findings
.iter()
.map(|f| {
json!({
"page": f.page.map(|p| p + 1),
"object": f.object.map(|r| r.num),
"object_generation": f.object.map(|r| r.generation),
"bbox_user_space": f.bbox,
"message": f.message,
})
})
.collect();
let findings: Vec<Value> = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect();
json!({ "rule": x.rule.id(), "name": x.rule.name(), "category": category_id(x.rule.category()), "status": status_id(x.status), "findings": findings })
})
.collect();
@ -59,183 +44,7 @@ pub(crate) fn report_json(r: &Report) -> Value {
})
}
fn review_hash(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
fn review_decisions(value: &Value, pages: usize) -> Result<Vec<Value>> {
let rows = value.as_array().ok_or_else(|| ToolError::InvalidArgs("decisions must be an array".into()))?;
let mut seen = BTreeSet::new();
rows.iter()
.map(|row| {
let fields = row.as_object().ok_or_else(|| ToolError::InvalidArgs("each decision must be an object".into()))?;
if fields.keys().any(|key| !matches!(key.as_str(), "rule" | "decision" | "page" | "note")) {
return Err(ToolError::InvalidArgs("unknown decision property".into()));
}
let rule = row["rule"].as_str().and_then(Rule::from_id).ok_or_else(|| ToolError::InvalidArgs("decision has an unknown rule".into()))?;
let decision = row["decision"].as_str().ok_or_else(|| ToolError::InvalidArgs("decision status is required".into()))?;
if !matches!(decision, "accepted" | "needs_work" | "skipped") {
return Err(ToolError::InvalidArgs("decision must be accepted, needs_work or skipped".into()));
}
let page = match row.get("page").filter(|value| !value.is_null()) {
Some(value) => {
let page = value.as_u64().ok_or_else(|| ToolError::InvalidArgs("decision page must be positive".into()))?;
if page == 0 || page > pages as u64 {
return Err(ToolError::InvalidArgs("decision page is outside the document".into()));
}
Some(page)
}
None => None,
};
let note = match row.get("note").filter(|value| !value.is_null()) {
Some(value) => {
let note = value.as_str().ok_or_else(|| ToolError::InvalidArgs("decision note must be text".into()))?;
if note.len() > 4096 {
return Err(ToolError::InvalidArgs("decision note exceeds 4096 bytes".into()));
}
Some(note)
}
None => None,
};
if !seen.insert((rule.id(), page)) {
return Err(ToolError::InvalidArgs("duplicate decision for a rule and page".into()));
}
Ok(json!({ "rule": rule.id(), "decision": decision, "page": page, "note": note }))
})
.collect()
}
/// A review sidecar is a few KiB of JSON; anything larger is not one.
const MAX_REVIEW_SIDECAR_BYTES: u64 = 1 << 20;
/// Read a review sidecar without trusting its size: the path is the caller's
/// and may name any file, so the read stops past `MAX_REVIEW_SIDECAR_BYTES` and
/// an oversized file is refused as not being a sidecar.
fn read_review_sidecar(path: &std::path::Path) -> Result<Vec<u8>> {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(path).and_then(|file| file.take(MAX_REVIEW_SIDECAR_BYTES.saturating_add(1)).read_to_end(&mut bytes)).map_err(failed)?;
if bytes.len() as u64 > MAX_REVIEW_SIDECAR_BYTES {
return Err(failed("file is not an accessibility review sidecar"));
}
Ok(bytes)
}
fn parse_review_sidecar(bytes: &[u8]) -> Result<Value> {
let value: Value = serde_json::from_slice(bytes).map_err(|_| failed("file is not an accessibility review sidecar"))?;
let hash = value["sha256"].as_str().ok_or_else(|| failed("review sidecar has no document hash"))?;
let Some(pages) = value["pages"].as_u64().and_then(|n| usize::try_from(n).ok()).filter(|n| *n > 0) else {
return Err(failed("unsupported or invalid accessibility review sidecar"));
};
if value["schema"] != 1 || hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(failed("unsupported or invalid accessibility review sidecar"));
}
review_decisions(&value["decisions"], pages).map_err(failed)?;
Ok(value)
}
fn html_escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
/// A self-contained, human-readable view of the same evidence as the JSON review record.
fn review_html(record: &Value) -> Result<String> {
let str_at = |value: &Value, key: &str| -> Result<String> {
value.get(key).and_then(Value::as_str).map(html_escape).ok_or_else(|| failed(format!("review record is missing {key}")))
};
let count = |value: &Value, key: &str| -> Result<u64> {
value.get(key).and_then(Value::as_u64).ok_or_else(|| failed(format!("review record is missing {key}")))
};
let before = &record["before"];
let after = &record["after"];
let mut html = format!(
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Accessibility review</title><style>\
body{{font:15px/1.5 system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;color:#1d1d1f}}\
table{{border-collapse:collapse;width:100%;margin:1rem 0}}th,td{{border:1px solid #aaa;padding:.45rem;text-align:left;vertical-align:top}}\
th{{background:#f2f2f2}}.hash{{overflow-wrap:anywhere;font-family:monospace}}\
</style></head><body><main><h1>Accessibility review</h1><p>Record created: {}</p>\n",
str_at(record, "date")?
);
html.push_str("<h2>Before and after</h2><table><caption>Accessibility checker outcomes</caption><thead><tr><th scope=\"col\">Version</th><th scope=\"col\">Document</th><th scope=\"col\">Passed</th><th scope=\"col\">Failed</th><th scope=\"col\">Needs manual check</th><th scope=\"col\">Skipped</th></tr></thead><tbody>");
for (label, version) in [("Before", before), ("After", after)] {
let summary = &version["summary"];
html.push_str(&format!(
"<tr><th scope=\"row\">{label}</th><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
str_at(version, "name")?,
count(summary, "passed")?,
count(summary, "failed")?,
count(summary, "manual")?,
count(summary, "skipped")?
));
}
html.push_str("</tbody></table>");
html.push_str(&format!(
"<p>Before SHA-256: <span class=\"hash\">{}</span><br>After SHA-256: <span class=\"hash\">{}</span></p>",
str_at(before, "sha256")?,
str_at(after, "sha256")?
));
html.push_str("<h2>Rule comparison</h2><table><caption>Before and after checker results</caption><thead><tr><th scope=\"col\">Rule</th><th scope=\"col\">Before</th><th scope=\"col\">Before findings</th><th scope=\"col\">After</th><th scope=\"col\">After findings</th><th scope=\"col\">Change</th></tr></thead><tbody>");
let rules = record["rules"].as_array().ok_or_else(|| failed("review record is missing rules"))?;
for row in rules {
let id = row["rule"].as_str().ok_or_else(|| failed("review record has a rule without an id"))?;
let name = Rule::from_id(id).map(Rule::name).unwrap_or(id);
let old = &row["before"];
let new = &row["after"];
let old_status = str_at(old, "status")?;
let new_status = str_at(new, "status")?;
let old_findings = count(old, "findings")?;
let new_findings = count(new, "findings")?;
let changed = old_status != new_status || old_findings != new_findings;
html.push_str(&format!(
"<tr><th scope=\"row\">{}</th><td>{old_status}</td><td>{old_findings}</td><td>{new_status}</td><td>{new_findings}</td><td>{}</td></tr>",
html_escape(name),
if changed { "Changed" } else { "Unchanged" }
));
}
html.push_str("</tbody></table><h2>Still requiring review</h2><ul>");
for id in record["review_required"].as_array().ok_or_else(|| failed("review record is missing review_required"))? {
let id = id.as_str().ok_or_else(|| failed("review record has an invalid review_required entry"))?;
html.push_str(&format!("<li>{}</li>", html_escape(Rule::from_id(id).map(Rule::name).unwrap_or(id))));
}
html.push_str("</ul>");
html.push_str(&format!("<p>{}</p></main></body></html>\n", str_at(record, "note")?));
Ok(html)
}
impl Automation {
pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?;
let tags = self.accessibility_tags(a)?;
Ok(json!({
"tags": tags["tags"], "count": tags["count"],
"note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.",
}))
}
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?;
let candidates: Vec<Value> = preview
.candidates
.into_iter()
.map(|c| {
json!({
"type": c.kind, "reason": c.reason, "text": c.text,
"text_truncated": c.text_truncated, "bbox_user_space": c.rect,
"font_size": c.size, "bold": c.bold, "needs_review": true,
})
})
.collect();
Ok(json!({
"page": page + 1, "candidates": candidates,
"unclassified_images": preview.unclassified_images,
"unclassified_text_blocks": preview.unclassified_text_blocks,
"existing_structure": preview.existing_structure,
"note": "Text roles are proposals only. Review images, tables, lists, annotations and existing tags before applying structure.",
}))
}
fn a11y_options(&self, a: &Args) -> Result<Options> {
let mut o = Options::default();
if a.opt_bool("all")?.unwrap_or(false) {
@ -284,120 +93,6 @@ impl Automation {
Ok(out)
}
fn review_record_value(&self, a: &Args) -> Result<Value> {
let id = |key| -> Result<DocId> {
let n = u64::try_from(a.int(key)?).map_err(|_| ToolError::InvalidArgs(format!("{key} must be positive")))?;
if n == 0 {
return Err(ToolError::InvalidArgs(format!("{key} must be positive")));
}
Ok(DocId(n))
};
let before_id = id("before")?;
let after_id = id("after")?;
if before_id == after_id {
return Err(ToolError::InvalidArgs("before and after must be separate open documents".into()));
}
let before = self.session.get(before_id).ok_or_else(|| failed(format!("no open document with id {}", before_id.0)))?;
let after = self.session.get(after_id).ok_or_else(|| failed(format!("no open document with id {}", after_id.0)))?;
let mut options = Options::default();
if a.opt_bool("all")?.unwrap_or(false) {
options.rules = Rule::ALL.into_iter().collect();
}
let before_report = before.accessibility_check(&options).ok_or_else(|| failed("the original document can't be read for checking"))?;
let after_report = after.accessibility_check(&options).ok_or_else(|| failed("the edited document can't be read for checking"))?;
if before_report.results.len() != after_report.results.len() {
return Err(failed("the two accessibility checks returned different rule sets"));
}
let mut rules = Vec::new();
let mut changed = Vec::new();
let mut review_required = Vec::new();
for (prior, current) in before_report.results.iter().zip(&after_report.results) {
if prior.rule != current.rule {
return Err(failed("the two accessibility checks returned different rule sets"));
}
let rule = prior.rule.id();
let before_status = status_id(prior.status);
let after_status = status_id(current.status);
let before_findings = prior.findings.len();
let after_findings = current.findings.len();
if before_status != after_status || before_findings != after_findings {
changed.push(rule);
}
if matches!(current.status, Status::Failed | Status::Manual) {
review_required.push(rule);
}
rules.push(json!({
"rule": rule,
"before": { "status": before_status, "findings": before_findings },
"after": { "status": after_status, "findings": after_findings },
}));
}
let (y, m, d) = self.session.today();
let hash = |bytes: &[u8]| Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect::<String>();
let record = json!({
"schema": 1,
"date": format!("{y}-{m:02}-{d:02}"),
"before": { "name": before.name, "sha256": hash(before.bytes.as_slice()), "summary": {
"passed": before_report.count(Status::Passed), "failed": before_report.count(Status::Failed),
"manual": before_report.count(Status::Manual), "skipped": before_report.count(Status::Skipped),
} },
"after": { "name": after.name, "sha256": hash(after.bytes.as_slice()), "summary": {
"passed": after_report.count(Status::Passed), "failed": after_report.count(Status::Failed),
"manual": after_report.count(Status::Manual), "skipped": after_report.count(Status::Skipped),
} },
"rules": rules,
"changed": changed,
"review_required": review_required,
"note": "Automated checks cover only part of accessibility. Review remaining findings manually and validate the final PDF independently.",
});
Ok(record)
}
pub(crate) fn a11y_review_record(&self, a: &Args) -> Result<Value> {
let record = self.review_record_value(a)?;
let path = self.resolve(a.str("path")?, true)?;
let bytes = serde_json::to_vec_pretty(&record).map_err(failed)?;
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "record": record }))
}
pub(crate) fn a11y_review_save(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let decisions = review_decisions(a.get("decisions").ok_or_else(|| Args::missing("decisions"))?, doc.info.pages.len())?;
let hash = review_hash(doc.bytes.as_slice());
let path = self.resolve(a.str("path")?, true)?;
if path.exists() {
let prior = read_review_sidecar(&path)?;
let prior = parse_review_sidecar(&prior)?;
if prior["sha256"] != hash && !a.opt_bool("replace")?.unwrap_or(false) {
return Err(failed("review sidecar belongs to a different PDF revision; pass replace: true to replace it"));
}
}
let (y, m, d) = self.session.today();
let sidecar =
json!({ "schema": 1, "date": format!("{y}-{m:02}-{d:02}"), "sha256": hash, "pages": doc.info.pages.len(), "decisions": decisions });
write_atomic(&path, &serde_json::to_vec_pretty(&sidecar).map_err(failed)?)?;
Ok(json!({ "path": path.to_string_lossy(), "review": sidecar }))
}
pub(crate) fn a11y_review_load(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let path = self.resolve(a.str("path")?, false)?;
let review = parse_review_sidecar(&read_review_sidecar(&path)?)?;
if review["sha256"] != review_hash(doc.bytes.as_slice()) {
return Err(failed("review sidecar belongs to a different PDF revision"));
}
Ok(json!({ "path": path.to_string_lossy(), "review": review }))
}
pub(crate) fn a11y_review_html(&self, a: &Args) -> Result<Value> {
let record = self.review_record_value(a)?;
let html = review_html(&record)?;
let path = self.resolve(a.str("path")?, true)?;
write_atomic(&path, html.as_bytes())?;
Ok(json!({ "path": path.to_string_lossy(), "record": record }))
}
pub(crate) fn a11y_fix(&mut self, a: &Args) -> Result<Value> {
let id = a.str("rule")?;
let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?;
@ -440,67 +135,10 @@ impl Automation {
self.session.apply(id, edit).map_err(failed)?;
self.accessibility_figures(a)
}
pub(crate) fn accessibility_tags(&self, a: &Args) -> Result<Value> {
let tags: Vec<Value> = self
.doc(a)?
.tags()
.into_iter()
.map(|t| {
json!({
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
"parent": t.parent.map(|p| json!({ "object": p.num, "generation": p.generation })), "depth": t.depth,
"title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt,
"language": t.language, "page": t.page.map(|p| p + 1),
})
})
.collect();
Ok(json!({ "count": tags.len(), "tags": tags }))
}
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
if object == 0 {
return Err(ToolError::InvalidArgs("object must be a positive tag object number".into()));
}
let generation = u16::try_from(a.int("generation")?)
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
let property_id = a.str("property")?;
let property = pdfcraft_engine::a11y::TagProperty::from_id(property_id)
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown tag property {property_id:?}")))?;
let value = a.opt_str("value")?.map(str::to_owned);
let id = self.doc(a)?.id;
self.session
.apply(id, pdfcraft_engine::Edit::SetTagProperty { object: pdfcraft_cos::ObjRef::new(object, generation), property, value })
.map_err(failed)?;
self.accessibility_tags(a)
}
pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result<Value> {
let reference = |number: &str, generation: &str| -> Result<pdfcraft_cos::ObjRef> {
let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?;
if n == 0 {
return Err(ToolError::InvalidArgs(format!("{number} must be a positive tag object number")));
}
let g = u16::try_from(a.int(generation)?)
.map_err(|_| ToolError::InvalidArgs(format!("{generation} must be a PDF object generation from accessibility_tags")))?;
Ok(pdfcraft_cos::ObjRef::new(n, g))
};
let object = reference("object", "generation")?;
let anchor = reference("anchor", "anchor_generation")?;
let placement = match a.str("placement")? {
"before" => pdfcraft_engine::a11y::TagPlacement::Before,
"after" => pdfcraft_engine::a11y::TagPlacement::After,
other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))),
};
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?;
self.accessibility_tags(a)
}
}
impl Automation {
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
if let Some(d) = a.opt_num("dpi")? {
settings.dpi = d.clamp(72.0, 600.0) as f32;
@ -634,7 +272,7 @@ impl Automation {
let path = self.resolve(a.str("path")?, true)?;
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?;
let bytes = self.doc_to_copy(a)?.export_office(format);
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
@ -662,56 +300,6 @@ impl Automation {
}))
}
pub(crate) fn print_readiness(&self, a: &Args) -> Result<Value> {
let report = self.doc(a)?.print_readiness();
let findings: Vec<Value> =
report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect();
let pages: Vec<Value> = report
.pages
.iter()
.map(|p| {
json!({
"page": p.page,
"media_mm": p.media_mm,
"crop_mm": p.crop_mm,
"trim_mm": p.trim_mm,
"bleed_mm": p.bleed_mm,
"trim_set": p.trim_set,
"bleed_set": p.bleed_set,
"bleed_margin_mm": p.bleed_margin_mm,
})
})
.collect();
let images: Vec<Value> = report
.images
.iter()
.map(|image| {
json!({
"page": image.page,
"name": image.name,
"ppi": image.ppi,
"pixels": image.pixels,
"size_mm": image.size_mm,
})
})
.collect();
let pdfa: Vec<Value> = report
.pdfa_issues
.iter()
.map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) }))
.collect();
Ok(json!({
"text": report.text(),
"findings": findings,
"pages": pages,
"images": images,
"output_intents": report.output_intents,
"fonts_not_embedded": report.fonts_not_embedded,
"pdfa_issues": pdfa,
"resolution_unknown": report.resolution_unknown,
}))
}
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let id = self.doc(a)?.id;
@ -928,23 +516,3 @@ impl Automation {
Ok(json!({ "enabled": self.session.javascript() }))
}
}
#[cfg(test)]
mod review_html_tests {
use super::*;
#[test]
fn document_names_are_escaped_in_review_html() {
let record = json!({
"date": "2026-10-12",
"before": { "name": "<script>alert('x')</script>.pdf", "sha256": "a", "summary": { "passed": 0, "failed": 1, "manual": 0, "skipped": 0 } },
"after": { "name": "safe.pdf", "sha256": "b", "summary": { "passed": 1, "failed": 0, "manual": 0, "skipped": 0 } },
"rules": [{ "rule": "primary-language", "before": { "status": "failed", "findings": 1 }, "after": { "status": "passed", "findings": 0 } }],
"review_required": [],
"note": "Review independently.",
});
let html = review_html(&record).unwrap();
assert!(html.contains("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;.pdf"));
assert!(!html.contains("<script>"));
}
}

View File

@ -1,276 +0,0 @@
//! One-shot folder jobs shared by CLI and MCP. Discovery finishes before any output is made.
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use pdfcraft_engine::{Session, ocr, optimize};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, failed};
const MAX_ENTRIES: usize = 10_000;
const MAX_DEPTH: usize = 64;
const MAX_INPUT: u64 = 512 * 1024 * 1024;
struct Recipe<'a> {
steps: Vec<&'a str>,
images: optimize::ImageSettings,
ocr: ocr::OcrSettings,
}
impl<'a> Recipe<'a> {
fn parse(auto: &Automation, args: &'a Args) -> Result<Self> {
let steps = args.strs("steps")?;
if steps.is_empty() || steps.len() > 3 {
return Err(Args::wrong("steps", "one to three unique steps: convert, ocr, optimize"));
}
let mut seen = HashSet::new();
for (i, step) in steps.iter().enumerate() {
if !matches!(*step, "convert" | "ocr" | "optimize") || !seen.insert(*step) || (*step == "convert" && i != 0) {
return Err(Args::wrong("steps", "unique convert/ocr/optimize steps, with convert first when present"));
}
}
for (keys, step) in [(&["ppi", "quality"][..], "optimize"), (&["dpi", "language", "skip_text_pages"][..], "ocr")] {
if !steps.contains(&step) && keys.iter().any(|k| args.get(k).is_some()) {
return Err(failed(format!("{} settings need the {step} step", keys.join("/"))));
}
}
let mut images = optimize::ImageSettings::REDUCE;
if let Some(ppi) = args.opt_num("ppi")? {
if !(9.0..=1600.0).contains(&ppi) {
return Err(Args::wrong("ppi", "9 to 1600"));
}
images.target_ppi = ppi;
images.above_ppi = ppi * 1.5;
}
if let Some(q) = args.opt_int("quality")? {
if !(1..=100).contains(&q) {
return Err(Args::wrong("quality", "1 to 100"));
}
images.compression = optimize::Compression::Jpeg(q as u8);
}
if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) {
return Err(Args::wrong("dpi", "72 to 600"));
}
Ok(Self { steps, images, ocr: auto.ocr_settings(args)? })
}
fn accepts(&self, path: &Path) -> bool {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase();
extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str()))
}
fn process(&self, path: &Path) -> Result<(Arc<Vec<u8>>, Vec<Value>)> {
// A fresh session per file bounds retained document state and never touches caller tabs.
let mut session = Session::new();
let mut input = Vec::new();
std::fs::File::open(path).map_err(failed)?.take(MAX_INPUT + 1).read_to_end(&mut input).map_err(failed)?;
if input.len() as u64 > MAX_INPUT {
return Err(failed("input exceeds 512 MiB"));
}
let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| failed("input name is not UTF-8"))?;
let mut bytes = Arc::new(input);
let mut log = Vec::new();
for step in &self.steps {
let before = bytes.len();
match *step {
"convert" => bytes = session.convert_to_pdf(name, &bytes)?.1,
"ocr" => {
let engine = ocr::engine().map_err(failed)?;
let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &engine, |_, _| true).map_err(failed)?;
log.push(
json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}),
);
bytes = result.bytes;
continue;
}
"optimize" => {
let id = session.open(name, None, bytes.clone(), None).map_err(failed)?;
let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() };
let (candidate, _) = session.optimized_bytes(id, &settings, &[])?;
session.close(id);
if candidate.len() < bytes.len() {
bytes = candidate;
}
}
_ => return Err(failed("unknown batch step")),
}
log.push(json!({"step":step,"bytes_before":before,"bytes_after":bytes.len()}));
}
Ok((bytes, log))
}
}
// Windows junctions are reparse points too; do not descend into them as ordinary folders.
fn linked(metadata: &std::fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
metadata.file_type().is_symlink()
}
}
/// Canonicalize the existing ancestor, including when the requested output does not exist yet.
fn absolute(path: &Path) -> Result<PathBuf> {
let mut ancestor = path;
let mut tail = Vec::new();
while !ancestor.exists() {
if ancestor.symlink_metadata().is_ok() {
return Err(failed("broken link in batch path"));
}
tail.push(ancestor.file_name().ok_or_else(|| failed("invalid batch path"))?);
ancestor = ancestor.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
}
let mut path = ancestor.canonicalize().map_err(failed)?;
path.extend(tail.iter().rev());
crate::lexical(&path).ok_or_else(|| failed("invalid batch path"))
}
struct Discovery {
files: BTreeMap<PathBuf, PathBuf>,
skipped: Vec<Value>,
seen: HashSet<PathBuf>,
visited: usize,
}
impl Discovery {
fn walk(&mut self, path: &Path, relative: &Path, out: &Path, recursive: bool, depth: usize) -> Result<()> {
self.visited += 1;
if self.visited > MAX_ENTRIES || depth > MAX_DEPTH {
return Err(failed("batch discovery exceeds 10000 entries or 64 folder levels"));
}
if depth > 0 && path.starts_with(out) {
return Ok(());
}
let metadata = path.symlink_metadata().map_err(|e| failed(format!("{}: {e}", path.display())))?;
if linked(&metadata) || (!metadata.is_file() && !metadata.is_dir()) {
self.skipped.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":"link or special file"}));
} else if (metadata.is_file() || depth == 0 || recursive) && self.seen.insert(path.to_path_buf()) {
if metadata.is_file() {
self.files.insert(path.to_path_buf(), relative.to_path_buf());
} else if depth == 0 || recursive {
// Bound the list before sorting it; never collect an unbounded folder listing.
let mut entries = Vec::new();
for entry in std::fs::read_dir(path).map_err(failed)? {
if entries.len() >= MAX_ENTRIES.saturating_sub(self.visited) {
return Err(failed("batch discovery exceeds 10000 entries"));
}
entries.push(entry.map_err(failed)?.path());
}
entries.sort();
for child in entries {
let name = child.file_name().ok_or_else(|| failed("entry has no name"))?;
self.walk(&child, &relative.join(name), out, recursive, depth + 1)?;
}
}
}
Ok(())
}
}
impl Automation {
pub(crate) fn doc_batch(&self, args: &Args) -> Result<Value> {
let recipe = Recipe::parse(self, args)?;
let recursive = args.opt_bool("recursive")?.unwrap_or(false);
let stop = args.opt_bool("stop_on_error")?.unwrap_or(false);
let paths = args.strs("paths")?;
if paths.is_empty() || paths.len() > 1000 {
return Err(Args::wrong("paths", "1 to 1000 files or folders"));
}
let out = absolute(&self.resolve(args.str("out_dir")?, true)?)?;
let mut roots = Vec::new();
for path in paths {
let resolved = self.resolve(path, false)?;
let original = self.root.as_ref().map_or_else(|| PathBuf::from(path), |root| root.join(path));
if linked(&original.symlink_metadata().map_err(failed)?) {
return Err(failed("input roots must be regular files or folders, not links"));
}
let real = absolute(&resolved)?;
if real.is_dir() && real.starts_with(&out) {
return Err(failed("out_dir must not contain an input folder or equal it"));
}
roots.push(real);
}
let mut discovery = Discovery { files: BTreeMap::new(), skipped: Vec::new(), seen: HashSet::new(), visited: 0 };
for root in roots {
let relative = if root.is_dir() { PathBuf::new() } else { PathBuf::from(root.file_name().ok_or_else(|| failed("input has no name"))?) };
discovery.walk(&root, &relative, &out, recursive, 0)?;
}
let mut files = discovery.skipped;
let (mut completed, mut failed_count) = (0, 0);
let mut stopped = false;
for (path, relative) in discovery.files {
if stopped || !recipe.accepts(&path) {
files.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":if stopped {"stopped after error"} else {"unsupported input extension"}}));
continue;
}
// Recheck confinement and links before reading a discovered file.
let result: Result<_> = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let current = self.resolve(path.to_str().ok_or_else(|| failed("input path is not UTF-8"))?, false)?;
if linked(&path.symlink_metadata().map_err(failed)?) || absolute(&current)? != path {
return Err(failed("input path changed after discovery"));
}
let (bytes, log) = recipe.process(&current)?;
let target = out.join(relative).with_extension("pdf");
let target = self.resolve(target.to_str().ok_or_else(|| failed("output path is not UTF-8"))?, true)?;
if !target.starts_with(&out) {
return Err(failed("output path leaves out_dir"));
}
let output = publish(&target, &bytes)?;
Ok((output, bytes.len(), log))
}))
.unwrap_or_else(|_| Err(failed("batch conversion panicked; no output was published")));
match result {
Ok((output, bytes, steps)) => {
completed += 1;
files.push(json!({"path":path.to_string_lossy(),"status":"ok","output":output.to_string_lossy(),"bytes":bytes,"steps":steps}));
}
Err(e) => {
failed_count += 1;
files.push(json!({"path":path.to_string_lossy(),"status":"error","error":e.to_string()}));
stopped = stop;
}
}
}
let skipped = files.len().saturating_sub(completed + failed_count);
Ok(json!({"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
}
}
/// Publish atomically without replacing any existing name, even if another writer wins a race.
fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
std::fs::create_dir_all(dir).map_err(failed)?;
let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?;
temp.write_all(bytes).map_err(failed)?;
temp.as_file().sync_all().map_err(failed)?;
let stem = target.file_stem().ok_or_else(|| failed("output has no stem"))?.to_string_lossy();
for n in 1..=10_000 {
let candidate = if n == 1 { target.to_path_buf() } else { dir.join(format!("{stem} ({n}).pdf")) };
match temp.persist_noclobber(&candidate) {
Ok(_) => return Ok(candidate),
Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists || candidate.symlink_metadata().is_ok() => temp = e.file,
Err(e) => return Err(failed(e.error)),
}
}
Err(failed("all 10000 output names are occupied"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovery_limits_fail_before_reading_another_entry() {
let mut discovery = Discovery { files: BTreeMap::new(), skipped: Vec::new(), seen: HashSet::new(), visited: MAX_ENTRIES };
assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, 0).unwrap_err().to_string().contains("10000"));
discovery.visited = 0;
assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, MAX_DEPTH + 1).unwrap_err().to_string().contains("64"));
}
}

View File

@ -469,45 +469,17 @@ impl Automation {
let at = to_user(&info, x, y);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let kind = a.str("type")?;
let scan = a.opt_bool("scan")?.unwrap_or(false);
let sensitivity = a.opt_int("sensitivity")?;
if (scan || sensitivity.is_some()) && a.opt_str("path")?.is_none() {
return Err(ToolError::InvalidArgs("scan and sensitivity need path, a photo of a signature on paper".into()));
}
if sensitivity.is_some() && !scan {
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
}
let requested_size = a.opt_num("font_size")?;
if requested_size.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("font_size is only for type text or date".into()));
}
let size = requested_size.unwrap_or(10.0);
if !(4.0..=300.0).contains(&size) {
return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into()));
}
if let Some(path) = a.opt_str("path")? {
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
}
let path = self.resolve(path, false)?;
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let image = if scan {
let sensitivity = match sensitivity {
None => pdfcraft_engine::signature_scan::DEFAULT_SENSITIVITY,
Some(s) => u8::try_from(s)
.ok()
.filter(|s| *s <= 100)
.ok_or_else(|| ToolError::InvalidArgs(format!("sensitivity must be 0–100, got {s}")))?,
};
let mut photo = pdfcraft_engine::SignaturePhoto::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let frame = photo.auto_frame();
photo.scan(frame, sensitivity, pdfcraft_engine::signature_scan::SCAN_MAX_SIDE).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
} else {
pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
};
let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
return self.apply(a, edit);
}
let size = 10.0;
let text_at = |t: &str| {
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
let h = size * 1.2 + 6.0;

View File

@ -164,9 +164,6 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color and align apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
return Err(bad("rotate must be a multiple of 90"));

View File

@ -12,7 +12,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod a11y;
mod batch;
mod comments;
mod content;
mod conventions;
@ -22,10 +21,8 @@ mod links;
pub mod mcp;
mod measure;
mod printing;
mod reading;
mod redact;
mod signing;
mod text_cache;
mod tools;
use std::collections::HashMap;
@ -97,11 +94,8 @@ const MAX_BOOKMARK_PAGE: usize = 1000;
const FIND_FIRST_BATCH: usize = 8;
const FIND_MAX_BATCH: usize = 128;
use text_cache::TextCache;
type PageTexts = Vec<(usize, std::result::Result<Arc<PageText>, String>)>;
const FIND_MAX_WARNINGS: usize = 100;
const FIND_MAX_WARNING_BYTES: usize = 1024;
/// Page texts of one document version: (the working bytes, one slot per page).
type TextCache = (Arc<Vec<u8>>, Vec<Option<Arc<PageText>>>);
/// A headless PdfCraft session driven by tool calls.
pub struct Automation {
@ -109,8 +103,8 @@ pub struct Automation {
root: Option<PathBuf>,
/// Synchronous renderers, rebuilt when a document's working bytes change.
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
/// Extracted page text, under one memory budget across all open documents.
texts: TextCache,
/// Extracted page text per document version (the working bytes it was taken from).
texts: HashMap<DocId, TextCache>,
}
impl Default for Automation {
@ -121,7 +115,7 @@ impl Default for Automation {
impl Automation {
pub fn new() -> Self {
Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() }
Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: HashMap::new() }
}
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
@ -170,7 +164,6 @@ impl Automation {
let a = Args(args);
let out = match name {
"doc_open" => self.doc_open(&a)?,
"doc_batch" => self.doc_batch(&a)?,
"doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::<Vec<_>>() }),
"doc_info" => {
let id = self.doc(&a)?.id;
@ -397,21 +390,6 @@ impl Automation {
.collect();
json!({ "page": page + 1, "count": lines.len(), "lines": lines })
}
"image_hit" => {
let page = self.page(&a)?;
let point = a
.get("point")
.and_then(Value::as_array)
.filter(|p| p.len() == 2)
.and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]))
.filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9))
.ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?;
let doc = self.doc(&a)?;
let p = &doc.info.pages[page];
let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from);
let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect();
json!({"page":page+1,"images":images})
}
"page_images" => {
let page = self.page(&a)?;
let doc = self.doc(&a)?;
@ -423,11 +401,7 @@ impl Automation {
.enumerate()
.map(|(i, im)| {
let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32));
let visible = im.visible_rect.map(|rect| {
let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32));
[r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))]
});
json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
json!({ "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
})
.collect();
json!({ "page": page + 1, "count": list.len(), "images": list })
@ -496,8 +470,6 @@ impl Automation {
json!({
"paragraph": i + 1,
"text": b.text,
"editor_text": b.editor_text,
"rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::<Vec<_>>()).collect::<Vec<_>>(),
"lines": b.lines.iter().map(|l| l + 1).collect::<Vec<_>>(),
"rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))],
"font": b.base_font,
@ -507,78 +479,6 @@ impl Automation {
.collect();
json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks })
}
"text_font_files" => {
let roots = if let Some(paths) = a.get("directories") {
let paths = paths
.as_array()
.filter(|p| p.len() <= 16)
.ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?;
paths
.iter()
.map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false))
.collect::<Result<Vec<_>>>()?
} else if let Some(root) = &self.root {
vec![root.clone()]
} else {
pdfcraft_engine::text_fonts::installed_directories()
};
json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::<Vec<_>>()})
}
"text_font_import" => {
let page = self.page(&a)?;
let path = self.resolve(a.str("path")?, false)?;
let file = std::fs::File::open(&path).map_err(failed)?;
use std::io::Read;
let mut bytes = Vec::new();
file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?;
if bytes.len() > 16 << 20 {
return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into()));
}
let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect();
let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?;
let added = self
.doc(&a)?
.text_editor_fonts(page)
.into_iter()
.find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved"));
if let Some(f) = added {
out["resource"] = json!(f.resource);
out["font"] = json!(f.face);
}
out
}
"text_fonts" => {
let page = self.page(&a)?;
json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::<Vec<_>>()})
}
"text_edit_layout" => {
let page = self.page(&a)?;
let doc = self.doc(&a)?;
let k = a.int("paragraph")?;
let blocks = doc.text_blocks(page);
if k < 1 || k as usize > blocks.len() {
return Err(ToolError::InvalidArgs("paragraph is out of range".into()));
}
let source = &blocks[k as usize - 1];
let text = a.opt_str("text")?.unwrap_or(&source.editor_text);
let style = pdfcraft_engine::BlockStyle {
size: a.opt_num("size")?,
width: a.opt_num("width")?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
ranges: parse_style_ranges(a.get("ranges"))?,
..Default::default()
};
let layout = doc.text_edit_layout(page, k as usize - 1, text, &style).map_err(failed)?;
let info = &doc.info.pages[page];
let view_rect = |r: [f64; 4]| {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
};
let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect();
json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect),
"draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts })
}
"text_edit" if a.get("paragraph").is_some() => {
let page = self.page(&a)?;
let n = self.doc(&a)?.text_blocks(page).len();
@ -589,8 +489,6 @@ impl Automation {
let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone();
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
let mut style = pdfcraft_engine::BlockStyle {
ranges: parse_style_ranges(a.get("ranges"))?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
bold: a.opt_bool("bold")?,
size: a.opt_num("size")?,
underline: a.opt_bool("underline")?,
@ -689,7 +587,6 @@ impl Automation {
"printers" => self.printers()?,
"printer_options" => self.printer_options(&a)?,
"link_list" => self.link_list(&a)?,
"link_open" => self.link_open(&a)?,
"link_add" => self.link_add(&a)?,
"link_edit" => self.link_edit(&a)?,
"link_delete" => self.link_delete(&a)?,
@ -722,8 +619,6 @@ impl Automation {
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
"accessibility_check" => self.a11y_check(&a)?,
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
"accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?,
"ocr_recognize" => self.ocr_recognize(&a)?,
"js_run" => self.js_run(&a)?,
"js_document_scripts" => self.js_document_scripts(&a)?,
@ -736,7 +631,6 @@ impl Automation {
"doc_compare" => self.doc_compare(&a)?,
"action_list" => self.action_list()?,
"pdfa_verify" => self.pdfa_verify(&a)?,
"print_readiness" => self.print_readiness(&a)?,
"doc_export_office" => self.doc_export_office(&a)?,
"pdfa_convert" => self.pdfa_convert(&a)?,
"action_run" => self.action_run(&a)?,
@ -746,16 +640,9 @@ impl Automation {
"ocr_status" => self.ocr_status()?,
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
"accessibility_report" => self.a11y_report(&a)?,
"accessibility_review_record" => self.a11y_review_record(&a)?,
"accessibility_review_save" => self.a11y_review_save(&a)?,
"accessibility_review_load" => self.a11y_review_load(&a)?,
"accessibility_review_html" => self.a11y_review_html(&a)?,
"accessibility_fix" => self.a11y_fix(&a)?,
"accessibility_figures" => self.accessibility_figures(&a)?,
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
"accessibility_tags" => self.accessibility_tags(&a)?,
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
"sign_id_create" => self.sign_id_create(&a)?,
"sign_document" => self.sign_document(&a)?,
"sign_keychain_ids" => {
@ -793,7 +680,6 @@ impl Automation {
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comments_hide" => self.comments_hide(&a)?,
"view_reading_colors" => self.view_reading_colors(&a)?,
"comments_summarize" => self.comments_summarize(&a)?,
"comment_edit" => self.comment_edit(&a)?,
"comment_delete" => self.comment_delete(&a)?,
@ -858,10 +744,6 @@ 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")? {
@ -876,13 +758,10 @@ impl Automation {
self.apply(a, Edit::FlattenFillSign)?;
}
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?;
// 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(&target, &bytes)?;
write_atomic(&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)?) }))
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": !full, "document": summary(self.doc(a)?) }))
}
fn doc_revisions(&mut self, a: &Args) -> Result<Value> {
@ -1384,7 +1263,7 @@ impl Automation {
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at })
}
/// Write freshly created bytes to `out` and/or open them as a new document.
@ -1521,13 +1400,6 @@ impl Automation {
}
Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())),
};
let field_conflicts = match a.opt_str("field_conflicts")? {
None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename,
Some("merge") => pdfcraft_organize::FieldConflicts::Merge,
Some(other) => {
return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}")));
}
};
let mut sources: Vec<CombineSource> = Vec::new();
let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new();
for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() {
@ -1555,8 +1427,8 @@ impl Automation {
sources.push((name, bytes, range));
}
let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
let combined = match groups {
None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts),
let bytes = match groups {
None => self.session.combine_unlocked(&sources, &passwords),
Some(groups) => {
// A path without a group is a file of its own: give it a key no group uses.
let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1));
@ -1571,15 +1443,11 @@ impl Automation {
}
});
}
self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts)
self.session.combine_grouped(&sources, &keys, &passwords)
}
}
.map_err(failed)?;
let mut result = self.deliver(a, "Combined", combined.bytes)?;
if !combined.renamed_fields.is_empty() {
result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::<Vec<_>>());
}
Ok(result)
self.deliver(a, "Combined", bytes)
}
fn doc_split(&mut self, a: &Args) -> Result<Value> {
@ -1666,9 +1534,8 @@ impl Automation {
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let colors = self.session.reading_colors(id).unwrap_or_default();
let fresh = || {
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() };
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
(doc.display.clone(), PageRenderer::new(doc.display.clone(), config))
};
let entry = self.renderers.entry(id).or_insert_with(fresh);
@ -1685,16 +1552,14 @@ impl Automation {
if !(1.0..=MAX_DPI).contains(&dpi) {
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
}
let scale = (dpi / 72.0) as f32;
let out = if let Some(block) = a.opt_int("hide_text_block")? {
let index = usize::try_from(block)
.ok()
.and_then(|n| n.checked_sub(1))
.ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?;
self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
} else {
self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false })
};
let out = self.renderer(id)?.render(RenderRequest {
page,
kind: RequestKind::Pixels,
tile: None,
scale: (dpi / 72.0) as f32,
tag: 0,
preview: false,
});
if let Some(e) = out.error {
return Err(failed(format!("page {} could not be rendered: {e}", page + 1)));
}
@ -1713,47 +1578,23 @@ impl Automation {
/// The text of `pages` (0-based), from the cache or extracted in parallel.
fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result<Vec<Arc<PageText>>> {
self.page_text_results(id, pages, &mut extract_parallel)?
.into_iter()
.map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1))))
.collect()
}
/// Keep per-page failures until the caller chooses strict or partial-result semantics.
fn page_text_results(
&mut self,
id: DocId,
pages: &[usize],
extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts,
) -> Result<PageTexts> {
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len());
let password: Option<Arc<str>> = doc.password.as_deref().map(Arc::from);
self.texts.prepare(id, &bytes);
let mut results = HashMap::new();
let mut missing = Vec::new();
for &p in pages {
if p >= n {
results.insert(p, Err("page is out of range".to_owned()));
} else if let Some(text) = self.texts.get(id, p) {
results.insert(p, Ok(text));
} else {
missing.push(p);
}
let entry = self.texts.entry(id).or_insert_with(|| (bytes.clone(), Vec::new()));
if !Arc::ptr_eq(&entry.0, &bytes) || entry.1.len() != n {
*entry = (bytes.clone(), vec![None; n]);
}
let mut missing: Vec<usize> = pages.iter().copied().filter(|p| entry.1.get(*p).is_some_and(Option::is_none)).collect();
missing.sort_unstable();
missing.dedup();
if !missing.is_empty() {
for (p, text) in extract(&bytes, password, &missing) {
if let Ok(text) = &text {
self.texts.insert(id, p, &bytes, text.clone());
}
results.insert(p, text);
for (p, text) in extract_parallel(&bytes, password, &missing) {
let text = Arc::new(text.map_err(|e| failed(format!("page {}: {e}", p + 1)))?);
if let Some(slot) = entry.1.get_mut(p) {
*slot = Some(text);
}
}
// The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A
// worker that fails to return a page is an explicit failure, never an empty success.
Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect())
pages.iter().map(|p| entry.1.get(*p).cloned().flatten().ok_or_else(|| failed(format!("page {} has no text", p + 1)))).collect()
}
fn text_extract(&mut self, a: &Args) -> Result<Value> {
@ -1776,13 +1617,9 @@ impl Automation {
/// Matches in document order, extracting page text in growing batches and stopping as soon as
/// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document
/// (#971). One match past the limit supplies `next`. Reached extraction failures are reported
/// as bounded warnings; `complete` requires both no continuation and no failed pages.
/// (#971). One match past the limit is looked for, so `complete` is true only when no more
/// exist; otherwise `next` holds the arguments that continue the search.
fn text_find(&mut self, a: &Args) -> Result<Value> {
self.text_find_with(a, &mut extract_parallel)
}
fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts) -> Result<Value> {
let doc = self.doc(a)?;
let (id, n) = (doc.id, doc.info.pages.len());
let query = a.str("query")?;
@ -1795,28 +1632,13 @@ impl Automation {
let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX);
let mut matches = Vec::new();
let mut next = None;
let mut warnings = Vec::new();
let mut warning_count = 0usize;
let (mut first, mut batch) = (start, FIND_FIRST_BATCH);
'search: while first < n {
let end = first.saturating_add(batch).min(n);
let pages: Vec<usize> = (first..end).collect();
let texts = self.page_text_results(id, &pages, extract)?;
for (p, text) in texts {
let text = match text {
Ok(text) => text,
Err(mut error) => {
warning_count += 1;
if warnings.len() < FIND_MAX_WARNINGS {
let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len()));
let truncated = end < error.len();
error.truncate(end);
warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated }));
}
continue;
}
};
let skip_here = if p == start { skip } else { 0 };
let texts = self.page_texts(id, &pages)?;
for (p, text) in pages.iter().zip(&texts) {
let skip_here = if *p == start { skip } else { 0 };
// One extra match proves that the caller needs a continuation. Include skipped
// matches on the starting page when bounding the temporary per-page result.
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1);
@ -1831,8 +1653,7 @@ impl Automation {
first = end;
batch = batch.saturating_mul(2).min(FIND_MAX_BATCH);
}
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches,
"warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings });
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none(), "matches": matches });
if let (Some(next), Some(o)) = (next, out.as_object_mut()) {
o.insert("next".into(), next);
}
@ -1960,53 +1781,6 @@ fn foreign_share(path: &Path, root: &Path) -> bool {
// ---- JSON helpers ----------------------------------------------------------------------------
fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
let Some(value) = value else { return Ok(Vec::new()) };
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
values
.iter()
.map(|v| {
let a = Args(v);
let bounds = a
.get("range")
.and_then(Value::as_array)
.filter(|v| v.len() == 2)
.ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?;
let start = bounds
.first()
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?;
let end = bounds
.get(1)
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?;
let family = a
.opt_str("font")?
.map(|f| match f {
"helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica),
"times" => Ok(pdfcraft_engine::FontFamily::Times),
"courier" => Ok(pdfcraft_engine::FontFamily::Courier),
_ => Err(Args::wrong("font", "helvetica, times or courier")),
})
.transpose()?
.map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) })
.transpose()?;
Ok(pdfcraft_engine::RangeStyle {
range: start..end,
family,
resource: a.opt_str("font_resource")?.map(str::to_owned),
size: a.opt_num("size")?,
color: a.opt_str("color")?.map(comments::parse_color).transpose()?,
underline: a.opt_bool("underline")?,
char_spacing: a.opt_num("char_spacing")?,
scale: a.opt_num("scale")?,
})
})
.collect()
}
/// Typed access to validated arguments.
struct Args<'a>(&'a Value);
@ -2157,9 +1931,6 @@ fn info(d: &Document) -> Value {
// (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed).
"xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
"xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })),
// A script of the form ran too long and was abandoned: its scripts no longer run in this
// document (calculations, buttons) until it is opened again.
"xfa_scripts_off": d.xfa_scripts_off(),
// What was rewritten from, or could not be written to, the XFA data.
"xfa_warnings": d.xfa_warnings,
"security": security,
@ -2195,27 +1966,13 @@ fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::RemoteGoTo { file, destination, new_window } => json!({
"remote_document": file,
"destination": match destination {
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
},
"new_window": new_window,
}),
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
},
})).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(),
@ -2253,12 +2010,12 @@ fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>>
}
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> PageTexts {
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> Vec<(usize, std::result::Result<PageText, String>)> {
let extract = |r: &mut PageRenderer, p: usize| {
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
match out.error {
Some(e) => Err(e),
None => Ok(out.text.unwrap_or_default()),
None => Ok(out.text.map(|t| (*t).clone()).unwrap_or_default()),
}
};
let config = RenderConfig { password, ..Default::default() };
@ -2269,7 +2026,7 @@ fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[u
return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect();
}
let next = std::sync::atomic::AtomicUsize::new(0);
let mut out: PageTexts = std::thread::scope(|s| {
let mut out: Vec<(usize, std::result::Result<PageText, String>)> = std::thread::scope(|s| {
let handles: Vec<_> = (0..workers)
.map(|_| {
s.spawn(|| {
@ -2310,24 +2067,13 @@ 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 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.
/// Write via a temporary file in the same directory, then rename over the target.
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.
@ -2342,9 +2088,7 @@ fn write_atomic_synced(
// Closed at the end of the block, before the rename.
let written = {
let mut file = file;
// 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))
file.write_all(bytes)
};
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
@ -2394,118 +2138,6 @@ mod tests {
}
}
fn search_document(a: &mut Automation, pages: usize) -> DocId {
a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap()
}
#[test]
fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() {
let mut a = Automation::new();
a.texts = TextCache::new(16 * 1024);
let first = search_document(&mut a, 200);
let second = search_document(&mut a, 200);
for id in [first, second] {
let result = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent" }));
assert_eq!(result["complete"], true);
assert!(a.texts.bytes() <= 16 * 1024);
assert!(a.texts.len() < 200, "an exhaustive search must not retain every page");
let found = json(&mut a, "text_find", json!({ "doc": id.0, "query": "Page", "limit": 1000 }));
assert_eq!(found["count"], 200, "eviction inside a batch must not discard its matches");
eprintln!("200 pages searched: {} resident pages, {} estimated bytes (budget 16384)", a.texts.len(), a.texts.bytes());
}
assert!(a.texts.get(first, 0).is_none());
let text = a.page_texts(first, &[0]).unwrap().remove(0);
assert_eq!(text.plain_text(), "Page 1");
let again = a.page_texts(first, &[0]).unwrap().remove(0);
assert!(Arc::ptr_eq(&text, &again), "cache hits reuse glyph storage");
assert!(a.texts.bytes() <= 16 * 1024);
json(&mut a, "page_delete", json!({ "doc": first.0, "pages": [1] }));
let edited = a.page_texts(first, &[0]).unwrap().remove(0);
assert_eq!(edited.plain_text(), "Page 2");
assert!(!Arc::ptr_eq(&text, &edited));
json(&mut a, "doc_close", json!({ "doc": first.0, "discard_changes": true }));
json(&mut a, "doc_close", json!({ "doc": second.0 }));
assert_eq!((a.texts.bytes(), a.texts.len()), (0, 0));
}
#[test]
fn strict_text_collection_refuses_partial_results_but_keeps_successful_pages() {
let mut a = Automation::new();
let id = search_document(&mut a, 3);
let error = a.page_texts(id, &[0, 3, 1]).unwrap_err();
assert!(error.to_string().contains("page 4: page is out of range"));
assert!(a.texts.get(id, 0).is_some());
assert!(a.texts.get(id, 1).is_some(), "successful extraction after the failed entry is retained");
let results = a.page_text_results(id, &[2], &mut |_, _, _| vec![]).unwrap();
assert!(results[0].1.is_err(), "a missing worker result must not become an empty page");
}
#[test]
fn search_keeps_matches_around_failed_pages_and_continuations() {
let mut a = Automation::new();
let id = search_document(&mut a, 16);
let mut extract = |bytes: &Arc<Vec<u8>>, password, pages: &[usize]| {
extract_parallel(bytes, password, pages)
.into_iter()
.map(|(p, result)| (p, if [2, 6].contains(&p) { Err(format!("synthetic page {} failure", p + 1)) } else { result }))
.collect()
};
let all = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut extract).unwrap();
assert_eq!(all["count"], 14);
assert_eq!(all["complete"], false);
assert_eq!(all["warning_count"], 2);
assert_eq!(all["warnings"][0]["page"], 3);
assert_eq!(all["warnings"][1]["page"], 7);
assert!(all.get("next").is_none());
assert_eq!(all["matches"][13]["page"], 16);
assert!(a.texts.get(id, 7).is_some(), "success after a failure in the same batch is cached");
let mut args = json!({ "doc": id.0, "query": "Page", "limit": 4 });
let mut matches = Vec::new();
let mut warned = Vec::new();
loop {
let part = a.text_find_with(&Args(&args), &mut extract).unwrap();
matches.extend(part["matches"].as_array().unwrap().iter().cloned());
warned.extend(part["warnings"].as_array().unwrap().iter().map(|w| w["page"].clone()));
if !part["warnings"].as_array().unwrap().is_empty() {
assert_eq!(part["complete"], false);
}
let Some(next) = part.get("next") else { break };
args["start_page"] = next["start_page"].clone();
args["skip"] = next["skip"].clone();
}
assert_eq!(matches, *all["matches"].as_array().unwrap(), "no readable match is lost or repeated");
assert_eq!(warned, vec![json!(3), json!(7)]);
}
#[test]
fn search_bounds_warning_volume_and_does_not_reach_distant_failures() {
let mut a = Automation::new();
let id = search_document(&mut a, 200);
let first = a
.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page", "limit": 1 })), &mut |bytes, password, pages| {
assert!(pages.iter().all(|p| *p < FIND_FIRST_BATCH), "a bounded search must not reach the bad page at the end");
extract_parallel(bytes, password, pages)
})
.unwrap();
assert_eq!(first["count"], 1);
assert_eq!(first["warning_count"], 0);
assert_eq!(first["next"], json!({ "start_page": 2, "skip": 0 }));
a.texts.remove(&id);
let failures = a
.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut |_, _, pages| {
pages.iter().map(|p| (*p, Err("界".repeat(2000)))).collect()
})
.unwrap();
assert_eq!(failures["count"], 0);
assert_eq!(failures["complete"], false);
assert!(failures.get("next").is_none());
assert_eq!(failures["warning_count"], 200);
assert_eq!(failures["warnings_truncated"], true);
let warnings = failures["warnings"].as_array().unwrap();
assert_eq!(warnings.len(), FIND_MAX_WARNINGS);
assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true));
}
#[test]
fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id()));
@ -2514,7 +2146,7 @@ mod tests {
std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone();
let extracted = |a: &Automation| a.texts.len();
let extracted = |a: &Automation| a.texts.values().map(|(_, pages)| pages.iter().filter(|t| t.is_some()).count()).sum::<usize>();
// #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages
// aren't extracted or cached.
@ -2702,79 +2334,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_staging_sync_keeps_the_destination_and_cleans_up() {
for exists in [false, true] {
let dir = staging_dir(if exists { "sync-existing" } else { "sync-new" });
let target = dir.join("out.pdf");
if exists {
std::fs::write(&target, "OLD").unwrap();
}
let result = write_atomic_synced(&target, b"NEW", [1], |_| {
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW", "sync follows writing");
if exists {
assert_eq!(read(&target), "OLD", "sync precedes publication");
}
Err(std::io::Error::other("injected storage sync failure"))
});
assert!(result.is_err(), "sync failure must refuse publication");
assert!(result.unwrap_err().to_string().contains("injected storage sync failure"));
if exists {
assert_eq!(read(&target), "OLD");
assert_eq!(listing(&dir), ["out.pdf"]);
} else {
assert!(!target.exists());
assert!(listing(&dir).is_empty());
}
std::fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() {
let dir = staging_dir("sync-dirty");
let target = dir.join("out.pdf");
let mut a = Automation::new().with_root(&dir).unwrap();
let id = search_document(&mut a, 2);
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
let old = std::fs::read(&target).unwrap();
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
let args = json!({"doc": id.0});
let before = summary(a.doc(&Args(&args)).unwrap());
let result = a.doc_save_with(&Args(&args), |path, bytes| {
write_atomic_synced(path, bytes, [1], |_| Err(std::io::Error::other("injected storage sync failure")))
});
assert!(result.is_err());
let doc = a.doc(&Args(&args)).unwrap();
assert!(doc.dirty);
assert_eq!(summary(doc), before, "failed save must not reset history or saved state");
assert_eq!(std::fs::read(&target).unwrap(), old);
assert_eq!(listing(&dir), ["out.pdf"]);
json(&mut a, "doc_save", args.clone());
assert!(!a.doc(&Args(&args)).unwrap().dirty);
assert_ne!(std::fs::read(&target).unwrap(), old);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn successful_staging_sync_precedes_publication() {
let dir = staging_dir("sync-success");
let target = dir.join("out.pdf");
std::fs::write(&target, "OLD").unwrap();
let mut called = false;
write_atomic_synced(&target, b"NEW", [1], |file| {
called = true;
assert_eq!(read(&target), "OLD");
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW");
file.sync_all()
})
.unwrap();
assert!(called);
assert_eq!(read(&target), "NEW");
assert_eq!(listing(&dir), ["out.pdf"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// The names in a folder: what a test can see was left behind.
fn listing(dir: &Path) -> Vec<String> {
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();

View File

@ -1,12 +1,8 @@
//! Link tools: list, add, edit and delete links; create links from URLs in the text; remove
//! all links. Rectangles are points from the top-left of the displayed page.
use pdfcraft_engine::{
Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle};
use serde_json::{Value, json};
use std::path::Path;
use crate::comments::parse_color;
use crate::{Args, Automation, Result, ToolError, failed};
@ -84,72 +80,6 @@ impl Automation {
Ok(json!({ "count": items.len(), "links": items }))
}
pub(crate) fn link_open(&mut self, a: &Args) -> Result<Value> {
let page_number = a.int("page")?;
let index_number = a.int("index")?;
let page = usize::try_from(page_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("page must be 1 or more"))?)
.map_err(|_| bad("page is out of range"))?;
let index = usize::try_from(index_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("index must be 1 or more"))?)
.map_err(|_| bad("index is out of range"))?;
let (source_path, file, destination, new_window) = {
let source = self.doc(a)?;
if page >= source.info.pages.len() {
return Err(bad(format!("page {page_number} is out of range: the document has {} pages", source.info.pages.len())));
}
let link = source
.info
.links
.iter()
.find(|link| link.page == page && link.index == index)
.ok_or_else(|| failed(format!("page {page_number} has no link {index_number} (see link_list)")))?;
let LinkTarget::RemoteGoTo { file, destination, new_window } = &link.target else {
return Err(bad("the selected link is not a supported local PDF GoToR link"));
};
(source.path.clone(), file.clone(), destination.clone(), *new_window)
};
let path = resolve_remote_document_path(source_path.as_deref().map(Path::new), &file).map_err(failed)?;
let allow_absolute = a.opt_bool("allow_absolute")?.unwrap_or(false);
if remote_document_path_is_absolute(&file) && !allow_absolute {
return Err(failed(
"this GoToR link names an absolute local path; review the target with link_list and pass allow_absolute: true to open it",
));
}
let path = self.resolve(&path.to_string_lossy(), false)?;
let bytes = read_remote_document(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id =
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
let (destination_applied, destination_value, warning) = match destination {
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
RemoteDestination::Named(name) => {
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
}
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
}
RemoteDestination::Page { page, view } => (
false,
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
Some("The PDF opened, but the link's page destination is out of range."),
),
};
let mut display_path = path.to_string_lossy().into_owned();
if let Some(root) = &self.root
&& let Ok(relative) = path.strip_prefix(root)
{
display_path = relative.to_string_lossy().into_owned();
}
Ok(json!({
"document": crate::summary(opened),
"path": display_path,
"destination_applied": destination_applied,
"destination": destination_value,
"warning": warning,
"new_window_hint": new_window,
}))
}
pub(crate) fn link_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?;
@ -196,27 +126,9 @@ impl Automation {
}
pub(crate) fn links_remove(&mut self, a: &Args) -> Result<Value> {
let pages = match a.opt_ints("pages")? {
Some(_) => Some(self.pages(a, "pages")?),
None => None,
};
let before = match &pages {
Some(ps) => self.doc(a)?.links.iter().filter(|l| ps.contains(&l.page)).count(),
None => self.doc(a)?.links.len(),
};
let mut out = self.apply(a, Edit::RemoveLinks { pages })?;
let before = self.doc(a)?.links.len();
let mut out = self.apply(a, Edit::RemoveLinks { pages: None })?;
out["removed"] = json!(before);
Ok(out)
}
}
fn dest_view_value(view: DestView) -> Value {
match view {
DestView::Top => json!({ "kind": "top" }),
DestView::Xyz { left, top, zoom } => json!({ "kind": "xyz", "left": left, "top": top, "zoom": zoom }),
DestView::Fit => json!({ "kind": "fit" }),
DestView::FitH { top } => json!({ "kind": "fit_h", "top": top }),
DestView::FitV { left } => json!({ "kind": "fit_v", "left": left }),
DestView::FitR { rect } => json!({ "kind": "fit_r", "rect": rect }),
}
}

View File

@ -329,7 +329,7 @@ fn guarded_tool(f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Valu
}
fn tool_content(name: &str, content: Vec<Content>) -> Value {
let failed = matches!(name, "command_batch" | "doc_batch")
let failed = name == "command_batch"
&& content.iter().any(|c| match c {
Content::Json(v) => v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0),
_ => false,

View File

@ -65,10 +65,9 @@ impl Automation {
o => return Err(bad(format!("unknown order {o:?}"))),
};
match Layout::multiple(per) {
Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple {
Layout::Multiple { cols, rows, .. } => Layout::Multiple {
cols,
rows,
per_sheet,
order,
border: a.opt_bool("border")?.unwrap_or(false),
auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true),

View File

@ -1,36 +0,0 @@
//! Reading colours: Acrobat's Replace Document Colors as a tool (#897, AGENTS.md §3).
//!
//! A per-document view setting: solid text and line-art paints remap for readability and the
//! page clears to the setting's background when rendering. The file itself never changes.
use pdfcraft_render::ReadingColors;
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
/// The modes `ReadingColors::parse` accepts, for error messages.
const MODES: &str = "off, black-on-white, white-on-black, yellow-on-black, green-on-black, custom";
impl Automation {
/// Set the document's reading colours. Colours are only meaningful with `mode: "custom"`;
/// `parse` enforces the rest and its errors are actionable.
pub(crate) fn view_reading_colors(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let Some(mode) = a.opt_str("mode")? else {
return Err(ToolError::InvalidArgs(format!("mode is required (one of: {MODES})")));
};
let colors = ReadingColors::parse(mode, a.opt_str("text")?, a.opt_str("background")?, a.opt_bool("only_black")?, a.opt_bool("line_art")?)
.map_err(ToolError::InvalidArgs)?;
if self.session.set_reading_colors(id, colors) {
// Cached renderers baked the old setting into their config; page_render must re-read it.
self.renderers.remove(&id);
}
Ok(json!({
"mode": colors.mode_name(),
"text": colors.text_hex(),
"background": colors.background_hex(),
"only_black": colors.params.is_some_and(|p| p.only_black),
"line_art": colors.params.is_some_and(|p| p.line_art),
}))
}
}

View File

@ -54,11 +54,6 @@ impl Automation {
if chosen.iter().filter(|c| **c).count() != 1 {
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into()));
}
let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false);
let whole_words = a.opt_bool("whole_words")?.unwrap_or(false);
if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() {
return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into()));
}
// Per searched word or phrase: how many marks it made.
let mut matched_words: Vec<(String, usize)> = Vec::new();
if a.get("rect").is_some() {
@ -100,11 +95,7 @@ impl Automation {
for (&p, text) in pages.iter().zip(&texts) {
let hits: Vec<(Option<usize>, _)> = match pattern {
Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
None => needles
.iter()
.enumerate()
.flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h)))
.collect(),
None => needles.iter().enumerate().flat_map(|(i, w)| text.find(w).into_iter().map(move |h| (Some(i), h))).collect(),
};
for (word, h) in hits {
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| info.pages[p].view_rect_to_quad(r)).collect();

View File

@ -1,177 +0,0 @@
//! Bounded, session-wide page-text reuse. Active tool results own their Arcs independently.
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Weak};
use pdfcraft_engine::DocId;
use pdfcraft_render::{PageText, TextGlyph};
const BYTE_BUDGET: usize = 64 * 1024 * 1024;
// Also bound bookkeeping for documents with thousands of empty pages.
const PAGE_BUDGET: usize = 4096;
type Key = (DocId, usize);
struct Cached {
version: Weak<Vec<u8>>,
text: Arc<PageText>,
bytes: usize,
stamp: u64,
}
pub(super) struct TextCache {
pages: HashMap<Key, Cached>,
order: BTreeMap<u64, Key>,
clock: u64,
bytes: usize,
budget: usize,
}
impl Default for TextCache {
fn default() -> Self {
Self::new(BYTE_BUDGET)
}
}
impl TextCache {
pub(super) fn new(budget: usize) -> Self {
Self { pages: HashMap::new(), order: BTreeMap::new(), clock: 0, bytes: 0, budget }
}
pub(super) fn prepare(&mut self, id: DocId, bytes: &Arc<Vec<u8>>) {
let version = Arc::downgrade(bytes);
// At most PAGE_BUDGET entries, independent of the document's page count. Weak pointers
// distinguish revisions without keeping an obsolete PDF byte buffer alive.
let stale: Vec<_> = self.pages.iter().filter(|(key, page)| key.0 == id && !page.version.ptr_eq(&version)).map(|(key, _)| *key).collect();
for key in stale {
self.remove_page(key);
}
}
pub(super) fn remove(&mut self, id: &DocId) {
let keys: Vec<_> = self.pages.keys().filter(|key| key.0 == *id).copied().collect();
for key in keys {
self.remove_page(key);
}
}
fn remove_page(&mut self, key: Key) {
if let Some(page) = self.pages.remove(&key) {
self.order.remove(&page.stamp);
self.bytes -= page.bytes;
}
}
fn stamp(&mut self) -> u64 {
if self.clock == u64::MAX {
self.pages.clear();
self.order.clear();
self.bytes = 0;
self.clock = 0;
}
self.clock += 1;
self.clock
}
pub(super) fn get(&mut self, id: DocId, page: usize) -> Option<Arc<PageText>> {
let stamp = self.stamp();
let entry = self.pages.get_mut(&(id, page))?;
self.order.remove(&entry.stamp);
entry.stamp = stamp;
self.order.insert(stamp, (id, page));
Some(entry.text.clone())
}
pub(super) fn insert(&mut self, id: DocId, page: usize, version: &Arc<Vec<u8>>, text: Arc<PageText>) {
let bytes = retained_bytes(&text);
self.remove_page((id, page));
// An oversized page is usable by this call, but never retained by the cache.
if bytes > self.budget {
return;
}
while self.bytes > self.budget - bytes || self.pages.len() >= PAGE_BUDGET {
let Some((_, key)) = self.order.first_key_value() else { break };
self.remove_page(*key);
}
let stamp = self.stamp();
self.pages.insert((id, page), Cached { version: Arc::downgrade(version), text, bytes, stamp });
self.order.insert(stamp, (id, page));
self.bytes += bytes;
}
#[cfg(test)]
pub(super) fn len(&self) -> usize {
self.pages.len()
}
#[cfg(test)]
pub(super) fn bytes(&self) -> usize {
self.bytes
}
}
// Estimate owned allocations, including spare capacity; bookkeeping has its own page-count cap.
fn retained_bytes(text: &PageText) -> usize {
let arrays = std::mem::size_of::<PageText>()
.saturating_add(2 * std::mem::size_of::<usize>())
.saturating_add(text.glyphs.capacity().saturating_mul(std::mem::size_of::<TextGlyph>()))
.saturating_add(text.line_of.capacity().saturating_mul(std::mem::size_of::<u32>()))
.saturating_add(text.space_before.capacity().saturating_mul(std::mem::size_of::<bool>()));
text.glyphs.iter().fold(arrays, |sum, glyph| sum.saturating_add(glyph.text.capacity()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn budget_counts_owned_capacity_and_lru_is_shared_across_documents() {
let mut text = PageText::default();
text.glyphs.reserve(32);
text.line_of.reserve(48);
text.space_before.reserve(64);
let mut word = String::with_capacity(1000);
word.push('a');
text.glyphs.push(TextGlyph { text: word, rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] });
let cost = retained_bytes(&text);
assert!(cost >= 32 * std::mem::size_of::<TextGlyph>() + 48 * 4 + 64 + 1000);
let text = Arc::new(text);
let version = Arc::new(vec![1]);
let mut cache = TextCache::new(cost * 2);
cache.insert(DocId(1), 0, &version, text.clone());
cache.insert(DocId(2), 0, &version, text.clone());
assert!(Arc::ptr_eq(&cache.get(DocId(1), 0).unwrap(), &text));
cache.insert(DocId(3), 0, &version, text.clone());
assert!(cache.get(DocId(2), 0).is_none(), "least recently used page in another document is evicted");
assert_eq!(cache.bytes, 2 * cost);
cache.insert(DocId(1), 0, &version, text.clone());
assert_eq!(cache.bytes, 2 * cost, "replacement does not double-count");
let next_version = Arc::new(vec![1]);
cache.prepare(DocId(1), &next_version);
assert!(cache.get(DocId(1), 0).is_none(), "equal bytes in a new Arc are a new version");
assert_eq!(cache.bytes, cost);
cache.remove(&DocId(3));
assert_eq!((cache.bytes, cache.len(), cache.order.len()), (0, 0, 0));
assert_eq!(Arc::strong_count(&version), 1, "the cache never retains a PDF byte buffer");
}
#[test]
fn oversized_pages_bypass_cache_and_empty_pages_have_a_bookkeeping_limit() {
let text = Arc::new(PageText::default());
let version = Arc::new(vec![]);
let cost = retained_bytes(&text);
let mut tiny = TextCache::new(cost - 1);
tiny.insert(DocId(1), 0, &version, text.clone());
assert_eq!((tiny.bytes, tiny.len()), (0, 0));
let mut cache = TextCache::default();
for page in 0..PAGE_BUDGET + 50 {
cache.insert(DocId(1), page, &version, text.clone());
}
assert_eq!(cache.len(), PAGE_BUDGET);
assert!(cache.get(DocId(1), 0).is_none());
assert!(cache.get(DocId(1), PAGE_BUDGET + 49).is_some());
assert_eq!(cache.bytes, PAGE_BUDGET * cost);
cache.clock = u64::MAX;
cache.insert(DocId(2), 0, &version, text);
assert_eq!((cache.bytes, cache.len()), (cost, 1), "recency counter rollover safely clears old state");
}
}

View File

@ -118,17 +118,6 @@ pub fn tools() -> Vec<ToolDef> {
let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." });
let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." });
vec![
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
.with(schema(json!({
"paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},
"out_dir": {"type":"string"},
"steps": {"type":"array","items":{"type":"string","enum":["convert","ocr","optimize"]},"minItems":1,"maxItems":3,"uniqueItems":true},
"recursive": {"type":"boolean"}, "stop_on_error": {"type":"boolean"},
"ppi": {"type":"number","minimum":9,"maximum":1600},
"quality": {"type":"integer","minimum":1,"maximum":100},
"dpi": {"type":"number","minimum":72,"maximum":600},
"language": {"type":"string"}, "skip_text_pages": {"type":"boolean"}
}), &["paths","out_dir","steps"])),
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
.cmd("file.open")
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
@ -156,13 +145,11 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords. Undoable.")
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }
}), &["doc", "page"])),
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
@ -181,10 +168,10 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.")
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match, in page order, with its page and line rectangles in points (origin top-left). Pages are read only until the limit is reached.")
.ro()
.cmd("edit.find")
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." },
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). The search stops once it has this many; `complete` says whether every match was returned, and when it is false `next` holds the start_page and skip that continue the search." },
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])),
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
@ -232,7 +219,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
&["doc", "pages"],
)),
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.")
.cmd("page.combine")
.with(schema(
json!({
@ -240,7 +227,6 @@ pub fn tools() -> Vec<ToolDef> {
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"passwords": { "type": "array", "items": { "type": ["string", "null"] } },
"groups": { "type": "array", "items": { "type": ["integer", "null"], "minimum": 0 } },
"field_conflicts": { "type": "string", "enum": ["rename", "merge"] },
"out": save_out,
"open": open,
}),
@ -413,7 +399,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_mark",
"Mark for redaction",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
)
.with(schema(
json!({
@ -422,8 +408,6 @@ pub fn tools() -> Vec<ToolDef> {
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"find": { "type": "string", "minLength": 1 },
"words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 },
"case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." },
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all)"),
@ -561,11 +545,6 @@ pub fn tools() -> Vec<ToolDef> {
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
&["doc", "page", "index"],
)),
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
.cmd("edit.link")
.with(schema(
@ -592,10 +571,10 @@ pub fn tools() -> Vec<ToolDef> {
t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.")
.cmd("edit.links_from_urls")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.")
t("links_remove", "Remove all links", "Remove every link in the document. Undoable.")
.destructive()
.cmd("edit.remove_links")
.with(schema(json!({ "doc": doc(), "pages": pages("whose links to remove (default: all)") }), &["doc"])),
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("measure_distance", "Measure distance", "Add an undoable two-point distance annotation using the scale of the first point's viewport.")
.cmd("measure.distance").with(measure_schema()),
t("measure_perimeter", "Measure perimeter", "Add an undoable connected-line length annotation. To include a closing edge, repeat the first point at the end.")
@ -730,19 +709,6 @@ pub fn tools() -> Vec<ToolDef> {
t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.")
.cmd("comment.hide_all")
.with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])),
t("view_reading_colors", "Set reading colours", "Set the document's reading colours (Acrobat's Replace Document Colors, in the Reading Options): solid text and line-art paints are remapped for readability and the page clears to the background colour in page_render. A view setting: the file doesn't change. mode is one of: off, black-on-white, white-on-black, yellow-on-black, green-on-black, or custom (which takes text and background as #RRGGBB). only_black maps only near-black paints (default false) and line_art also maps line-art paints (default true); both override the mode's preset. A flag that differs from the mode's preset default makes the returned mode read custom, because the resulting params no longer match any preset. Returns the resulting setting.")
.cmd("a11y.reading_options")
.with(schema(
json!({
"doc": doc(),
"mode": { "type": "string", "enum": ["off", "black-on-white", "white-on-black", "yellow-on-black", "green-on-black", "custom"] },
"text": { "type": "string", "description": "With mode custom: the text colour, #RRGGBB." },
"background": { "type": "string", "description": "With mode custom: the background colour, #RRGGBB." },
"only_black": { "type": "boolean", "description": "Map only near-black paints. Default false." },
"line_art": { "type": "boolean", "description": "Also map line-art paints. Default true." },
}),
&["doc", "mode"],
)),
t("comments_summarize", "Summarize comments", "Make a PDF summarising every comment (number, author, type, date, text, replies), sorted by page, author, date or type.")
.cmd("comment.summarize")
.with(schema(
@ -873,12 +839,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "folder"],
)),
t("accessibility_autotag_preview", "Preview automatic tags", "Propose paragraph and heading tags for visible text blocks on one page. Returns page bounds, tentative roles and reasons. Does not change the PDF or claim accessibility compliance. Images, tables, lists, annotations and existing structure require separate review.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("accessibility_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
.ro()
.cmd("a11y.check")
.with(schema(
@ -905,34 +866,6 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "path"],
)),
t("accessibility_review_record", "Accessibility review record", "Compare the same accessibility rules on two open versions of a PDF and write a JSON record to path. Includes document SHA-256 hashes, before/after rule statuses and finding counts, changed rules, and rules still needing review. Does not change either PDF or claim accessibility compliance. Open the original and edited copies as separate documents.")
.destructive()
.with(schema(
json!({
"before": doc(),
"after": doc(),
"path": { "type": "string" },
"all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." },
}),
&["before", "after", "path"],
)),
t("accessibility_review_save", "Save accessibility review decisions", "Write a local JSON sidecar for an open PDF with rule-level human review decisions. The sidecar records the PDF SHA-256; replacing one tied to different bytes requires replace: true. Does not change the PDF or assert compliance.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "decisions": { "type": "array", "items": { "type": "object", "properties": { "rule": { "type": "string" }, "decision": { "type": "string", "enum": ["accepted", "needs_work", "skipped"] }, "page": { "type": "integer", "minimum": 1 }, "note": { "type": "string" } }, "required": ["rule", "decision"], "additionalProperties": false } }, "replace": { "type": "boolean" } }), &["doc", "path", "decisions"])),
t("accessibility_review_load", "Open accessibility review decisions", "Read a local review sidecar only when its recorded SHA-256 matches the open PDF. Returns the decisions without changing the PDF.")
.ro()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("accessibility_review_html", "Accessibility review summary", "Compare two open versions of a PDF and write a self-contained HTML summary of the same evidence as accessibility_review_record: document hashes, outcome counts, rule changes, and rules still requiring review. Does not change either PDF or claim accessibility compliance.")
.destructive()
.with(schema(
json!({
"before": doc(),
"after": doc(),
"path": { "type": "string" },
"all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." },
}),
&["before", "after", "path"],
)),
t("accessibility_fix", "Fix an accessibility problem", "Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order). Returns the rule's new status. Undoable.")
.with(schema(json!({ "doc": doc(), "rule": { "type": "string", "enum": ["primary-language", "title", "tab-order"] }, "value": { "type": "string" } }), &["doc", "rule"])),
t("accessibility_figures", "List figures", "The tagged figures (Figure elements, through the role map) in document order: figure number, page, alternate text and where the figure is drawn (top-left-origin points).")
@ -942,25 +875,6 @@ pub fn tools() -> Vec<ToolDef> {
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
.cmd("a11y.alt_text")
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.")
.ro()
.cmd("a11y.tags")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"anchor": { "type": "integer", "minimum": 1 },
"anchor_generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"placement": { "type": "string", "enum": ["before", "after"] },
}), &["doc", "object", "generation", "anchor", "anchor_generation", "placement"])),
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
@ -983,16 +897,13 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: paragraphs in reading order, headings from larger text, bold and italic, images where they fall, a page break between pages.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
.ro()
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"], "description": "Default 2b." } }), &["doc"])),
t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
@ -1085,7 +996,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
@ -1095,10 +1006,7 @@ pub fn tools() -> Vec<ToolDef> {
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date", "signature", "initials"] },
"at": point(),
"text": { "type": "string", "minLength": 1 },
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
"format": { "type": "string", "description": "For type date: this pattern instead of the date-format preference, e.g. \"dd.mm.yyyy\"." },
"language": { "type": "string", "description": "For type date: month and weekday names in this language code (see fill_sign_date_format) instead of the preference." },
"author": { "type": "string" },
@ -1156,15 +1064,11 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_hit", "Hit test page artwork", "List image/Form numbers whose placement and all clipping paths contain point [x,y] in top-left-origin points. Bezier curves, even-odd/nonzero fill and holes are respected. Read only.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}}), &["doc","page","point"])),
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, full placement rect and visible_rect after page/path clipping (top-left-origin points; null if fully hidden), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.")
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new box in top-left-origin points), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
@ -1182,30 +1086,15 @@ pub fn tools() -> Vec<ToolDef> {
.destructive()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])),
t("text_font_files", "List installed document fonts", "Discover openly licensed, embeddable TrueType/OpenType files on request. Optional directories (at most 16) restrict the scan; with --root, only that root is scanned by default and supplied folders must stay inside it. The scan skips symlinks, collections and invalid/licence-restricted fonts, with depth, entry and byte budgets. Select a returned path with text_font_import.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"directories":{"type":"array","items":{"type":"string"},"maxItems":16}}), &[])),
t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.")
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])),
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (lines grouped by font, size, alignment and spacing): number, text, its line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("text_edit_layout", "Measure text draft", "Measure a paragraph draft using the same PDF rewrite as Apply, without changing the document. Returns source_rect, draft_rect (top-left-origin points), overflow, and the embedded sfnt source faces available to the inline editor. Text defaults to the unchanged paragraph. Optional size, width, font_resource and disjoint UTF-8 ranges preview formatting changes.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])),
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
"paragraph": { "type": "integer", "minimum": 1 },
"font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." },
"ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." },
"text": { "type": "string" },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] },
"bold": { "type": "boolean" },

File diff suppressed because it is too large Load Diff

View File

@ -1,230 +0,0 @@
use pdfcraft_automation::{Automation, Content};
use pdfcraft_engine::Session;
use serde_json::{Value, json};
fn result(auto: &mut Automation, args: Value) -> Value {
match auto.call("doc_batch", &args).unwrap().remove(0) {
Content::Json(value) => value,
_ => panic!("expected a report"),
}
}
// 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()
}
#[test]
fn batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir_all(dir.path().join("input/output/sub")).unwrap();
std::fs::write(dir.path().join("input/a.txt"), "Hello").unwrap();
std::fs::write(dir.path().join("input/b.pdf"), b"broken").unwrap();
std::fs::write(dir.path().join("input/sub/a.txt"), "World").unwrap();
std::fs::write(dir.path().join("input/notes.bin"), b"ignore").unwrap();
std::fs::write(dir.path().join("input/output/sub/a.pdf"), b"precious").unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let args = json!({"paths":["input","input/a.txt"],"steps":["convert","optimize"],"out_dir":"input/output","recursive":true});
let report = result(&mut auto, args.clone());
assert_eq!(report["completed"], 2, "{report}");
assert_eq!(report["failed"], 1);
assert_eq!(report["skipped"], 1);
assert_eq!(std::fs::read(dir.path().join("input/output/sub/a.pdf")).unwrap(), b"precious");
assert_eq!(std::fs::read_to_string(dir.path().join("input/a.txt")).unwrap(), "Hello");
for name in ["a.pdf", "sub/a (2).pdf"] {
let bytes = std::fs::read(dir.path().join("input/output").join(name)).unwrap();
let mut session = Session::new();
let id = session.open(name, None, std::sync::Arc::new(bytes), None).unwrap();
assert_eq!(session.get(id).unwrap().info.pages.len(), 1);
}
assert!(!dir.path().join("input/output/b.pdf").exists());
assert!(auto.session().docs().is_empty());
let again = result(&mut auto, args);
assert_eq!(again["completed"], 2, "previous outputs must not become new inputs: {again}");
assert!(dir.path().join("input/output/a (2).pdf").exists());
}
#[test]
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();
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);
assert_eq!(report["completed"], 0);
assert_eq!(report["skipped"], 1);
assert_eq!(report["stopped"], true);
assert!(!dir.path().join("out").exists());
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() <= original.len() as u64);
}
#[test]
fn invalid_batch_arguments_and_outside_paths_write_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
for change in [
json!({"steps":["convert","convert"]}),
json!({"steps":["optimize","convert"]}),
json!({"steps":[]}),
json!({"steps":["nope"]}),
json!({"recursive":"yes"}),
json!({"paths":[]}),
json!({"paths":["input","../outside"]}),
json!({"out_dir":"../outside"}),
json!({"out_dir":"input"}),
json!({"out_dir":"."}),
json!({"quality":0}),
json!({"quality":"80"}),
json!({"ppi":2000}),
json!({"steps":["ocr"],"language":"not-a-language"}),
json!({"steps":["ocr"],"dpi":1}),
] {
let mut args = json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"});
args.as_object_mut().unwrap().extend(change.as_object().unwrap().clone());
assert!(auto.call("doc_batch", &args).is_err(), "{args}");
assert!(!dir.path().join("out").exists(), "invalid arguments must be refused before writing");
}
}
#[test]
fn explicitly_selected_subfolder_is_processed_without_recursive() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input","input/sub"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["completed"], 1, "{report}");
assert!(dir.path().join("out/page.pdf").exists());
}
#[test]
fn explicit_file_can_write_a_numbered_copy_beside_its_source() {
let dir = tempfile::tempdir().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(), original);
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn non_utf8_discovered_names_are_reported_without_panicking() {
use std::os::unix::ffi::OsStrExt;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let invalid = dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf"));
if let Err(error) = std::fs::write(&invalid, pdf()) {
// APFS rejects this filename before batch discovery can see it. Keep the
// discovery assertions on filesystems that allow it; no other I/O error
// is an unsupported fixture.
#[cfg(target_os = "macos")]
if error.raw_os_error() == Some(92) {
assert!(!invalid.exists());
assert_eq!(std::fs::read_dir(dir.path().join("input")).unwrap().count(), 0);
assert!(!dir.path().join("out").exists());
let valid = dir.path().join("input/valid-λ.pdf");
let original = pdf();
std::fs::write(&valid, &original).unwrap();
assert_eq!(std::fs::read(&valid).unwrap(), original);
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), original);
eprintln!(
"non-UTF-8 fixture capability unavailable: macOS filesystem rejected creation with EILSEQ92; valid-Unicode batch companion passed; invalid-name discovery still requires Linux/FreeBSD or a supporting filesystem"
);
return;
}
panic!("could not create the non-UTF-8 fixture: {error}");
}
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["failed"], 1);
assert!(report["files"][0]["error"].as_str().unwrap().contains("UTF-8"));
}
#[cfg(unix)]
#[test]
fn directory_links_are_not_followed_and_output_links_cannot_escape() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir(dir.path().join("outside")).unwrap();
std::fs::create_dir(dir.path().join("out")).unwrap();
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
std::os::unix::fs::symlink(dir.path().join("input"), dir.path().join("input/loop")).unwrap();
std::os::unix::fs::symlink(dir.path().join("outside"), dir.path().join("out/sub")).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out","recursive":true}));
assert_eq!(report["skipped"], 1);
assert_eq!(report["failed"], 1);
assert_eq!(std::fs::read_dir(dir.path().join("outside")).unwrap().count(), 0);
}
#[test]
fn batch_ocr_uses_existing_models_or_reports_the_missing_models_per_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["ocr","optimize"],"out_dir":"out"}));
if pdfcraft_engine::ocr::available() {
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["files"][0]["steps"][0]["skipped_pages"], 1);
assert!(dir.path().join("out/page.pdf").exists());
} else {
assert_eq!(report["failed"], 1, "{report}");
assert!(report["files"][0]["error"].as_str().unwrap().contains("model"), "{report}");
assert!(!dir.path().join("out/page.pdf").exists());
}
}
#[test]
fn batch_output_collisions_are_safe_under_concurrent_writers() {
let dir = tempfile::tempdir().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)
.map(|_| {
scope.spawn(|| {
let mut auto = Automation::new().with_root(dir.path()).unwrap();
barrier.wait();
result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"out"}))
})
})
.collect();
let reports: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(reports.iter().all(|r| r["completed"] == 1), "{reports:?}");
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(), original);
}
assert_eq!(std::fs::read_dir(dir.path().join("out")).unwrap().count(), 2, "no staging files remain");
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_batch_errors_keep_the_structured_per_file_report() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("broken.pdf"), b"broken").unwrap();
let input = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"doc_batch","arguments":{"paths":["broken.pdf"],"steps":["optimize"],"out_dir":"out"}}}).to_string() + "\n";
let mut out = Vec::new();
pdfcraft_automation::mcp::McpServer::new(Automation::new().with_root(dir.path()).unwrap()).serve(input.as_bytes(), &mut out).unwrap();
let report: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(report["result"]["isError"], true, "{report}");
assert_eq!(report["result"]["structuredContent"]["failed"], 1);
}

View File

@ -125,19 +125,12 @@ pub fn parse(data: &[u8]) -> Parsed {
start.get_or_insert(at);
operands.push(Object::Null);
}
b"BI" => match inline_image(data, lx.pos) {
Ok((dict, img, end)) => {
lx.pos = end;
out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end });
operands.clear();
}
Err(end) => {
out.skipped += end.saturating_sub(at);
lx.pos = end;
start = None;
operands.clear();
}
},
b"BI" => {
let (dict, img, end) = inline_image(data, lx.pos);
lx.pos = end;
out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end });
operands.clear();
}
_ => {
let s = start.take().unwrap_or(at);
out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos });
@ -148,9 +141,8 @@ pub fn parse(data: &[u8]) -> Parsed {
out
}
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize> {
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`).
fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec<u8>, usize) {
let mut lx = Lexer::new(data, pos);
let mut dict = Dict::new();
loop {
@ -158,32 +150,17 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize
if lx.eat_keyword(b"ID") {
break;
}
let at = lx.pos;
let entry = match lx.object() {
Ok(Object::Name(k)) => lx.object().ok().map(|v| (k, v)),
_ => None,
let Ok(Object::Name(k)) = lx.object() else {
return (dict, Vec::new(), data.len());
};
let Some((k, v)) = entry else {
// A stream that ends inside the parameters is cut short, not unreadable: keep what was read.
if at >= data.len() {
return Ok((dict, Vec::new(), data.len()));
}
// Without an `EI` after it, the image runs to the end of the stream.
return Err(end_of_image(data, at).map_or(data.len(), |(_, end)| end));
let Ok(v) = lx.object() else {
return (dict, Vec::new(), data.len());
};
dict.set(k, v);
}
// One whitespace byte separates ID from the data.
let begin = (lx.pos + 1).min(data.len());
Ok(match end_of_image(data, begin) {
Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
None => (dict, data[begin..].to_vec(), data.len()),
})
}
/// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The
/// data ends at whitespace + `EI` + (whitespace, a delimiter or the end).
fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> {
// The data ends at whitespace + `EI` + (whitespace or end). Search for that pattern.
let mut i = begin;
while i + 2 <= data.len() {
if data[i] == b'E'
@ -192,11 +169,11 @@ fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> {
&& data.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b))
{
let end_data = if i > begin && is_ws(data[i - 1]) { i - 1 } else { i };
return Some((end_data, i + 2));
return (dict, data[begin..end_data].to_vec(), i + 2);
}
i += 1;
}
None
(dict, data[begin..].to_vec(), data.len())
}
/// Write one operator (with a trailing newline).

View File

@ -48,36 +48,6 @@ fn damage_is_skipped_not_fatal() {
assert!(parse(b"BI /W 1").ops.len() == 1);
}
/// Issue #1318: an inline image whose dictionary can't be read is skipped up to its `EI`, counted,
/// and what follows it is parsed.
#[test]
fn an_unreadable_inline_image_is_skipped_to_its_end() {
let src = b"q BI /W 4 H 4 ID aa EI Q BT (After) Tj ET";
let p = parse(src);
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"q"[..], b"Q", b"BT", b"Tj", b"ET"]);
assert_eq!(p.skipped, b"BI /W 4 H 4 ID aa EI".len());
let control = parse(b"q BI /W 4 /H 4 ID aa EI Q BT (After) Tj ET");
assert_eq!(control.skipped, 0);
assert_eq!(control.ops.len(), 6);
}
/// Issue #1318, follow-up: without an `EI`, an unreadable inline image runs to the end of the
/// stream, and those bytes are counted. A readable one whose data is cut short is kept, data and
/// all, so nothing of it is skipped.
#[test]
fn an_unreadable_inline_image_without_ei_is_counted() {
let p = parse(b"q BI /W 4 H 4 ID aa");
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"q"[..]]);
assert_eq!(p.skipped, b"BI /W 4 H 4 ID aa".len());
let control = parse(b"q BI /W 4 /H 4 ID aa");
assert_eq!(control.skipped, 0);
let names: Vec<&[u8]> = control.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"q"[..], b"BI"]);
assert_eq!(control.ops[1].inline.as_ref().map(|(_, data)| data.as_slice()), Some(&b"aa"[..]));
}
#[test]
fn matrices_compose_and_invert() {
let a = Matrix([2.0, 0.0, 0.0, 3.0, 10.0, 20.0]);

View File

@ -105,8 +105,6 @@ pub struct Document {
entries: Arc<BTreeMap<u32, XrefEntry>>,
trailer: Dict,
revisions: Arc<Vec<Revision>>,
/// See [`Self::xref_oddities`].
xref_oddities: Arc<Vec<(u64, u32)>>,
repair_log: Arc<Vec<String>>,
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
@ -221,7 +219,6 @@ impl Document {
entries: Arc::new(BTreeMap::new()),
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@ -285,7 +282,6 @@ impl Document {
entries: Arc::new(BTreeMap::new()),
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@ -308,11 +304,10 @@ impl Document {
let parsed = doc.read_xref_chain(&mut log);
let mut authenticated = false;
let ok = match parsed {
Ok((entries, trailer, revisions, oddities)) => {
Ok((entries, trailer, revisions)) => {
doc.entries = Arc::new(entries);
doc.trailer = trailer;
doc.revisions = Arc::new(revisions);
doc.xref_oddities = Arc::new(oddities);
// Authenticate before checking the catalog: it may sit in an encrypted object
// stream, which reads as garbage until the key is known.
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
@ -592,36 +587,6 @@ impl Document {
self.entries.get(&num).cloned()
}
/// Cross-reference entries this reader did not take at face value, as (byte offset of the
/// section, object number): an in-use entry at offset 0, a second and different entry for
/// one object in the same section, a reserved cross-reference stream entry type, or a
/// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside
/// and keeps what it already had for the object. Another reader, the page renderer among
/// them, may follow it instead, so the two can then show different objects for one number.
/// Code that compares revisions (signature validation) must not call such an object
/// unchanged.
pub fn xref_oddities(&self) -> &[(u64, u32)] {
&self.xref_oddities
}
/// Whether the cross-reference entry of `num` locates that object itself: its `num G obj`
/// header at exactly the recorded offset, or the member with that number at the recorded
/// position of its object stream. When it does not, [`Self::get`] still finds the object by
/// searching, but another reader may resolve the number differently. Ignores unsaved edits.
pub fn xref_entry_is_exact(&self, num: u32) -> bool {
match self.entries.get(&num) {
None | Some(XrefEntry::Free { .. }) => true,
Some(XrefEntry::InFile { offset, .. }) => {
let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num);
// Offsets may count from a header that does not start the file.
usize::try_from(*offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
}
Some(XrefEntry::InStream { stream, index }) => {
self.objstm(*stream).is_ok_and(|stm| usize::try_from(*index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num))
}
}
}
/// Object `num` has an unsaved edit.
pub fn is_edited(&self, num: u32) -> bool {
self.overlay.contains_key(&num)
@ -986,7 +951,7 @@ impl Document {
}
#[allow(clippy::type_complexity)]
fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>), CosError> {
let data = &self.data;
let tail_start = data.len().saturating_sub(4096);
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
@ -998,7 +963,6 @@ impl Document {
let mut entries = BTreeMap::new();
let mut trailer: Option<Dict> = None;
let mut revisions = Vec::new();
let mut oddities = Vec::new();
let mut visited = HashSet::new();
let mut next = Some(first);
while let Some(off) = next.take() {
@ -1008,15 +972,12 @@ impl Document {
}
// This section's own entries; older sections only fill what newer ones left out.
let mut sect = BTreeMap::new();
// Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]).
let mut odd = Vec::new();
let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) {
let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect) {
Ok(v) => v,
Err(e) if self.header_offset > 0 => {
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
sect.clear();
odd.clear();
self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)?
self.read_section(off as usize + self.header_offset, &mut sect)?
}
Err(e) => {
if revisions.is_empty() {
@ -1031,23 +992,17 @@ impl Document {
// free entries it gives objects only a cross-reference stream can locate.
if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
let mut stm = BTreeMap::new();
match self.read_section(x as usize, &mut stm, &mut odd) {
match self.read_section(x as usize, &mut stm) {
Ok(_) => {
for (num, e) in stm {
match sect.get(&num) {
None | Some(XrefEntry::Free { .. }) => {
sect.insert(num, e);
}
// The table already places the object somewhere else.
Some(old) if *old != e => odd.push(num),
Some(_) => {}
if sect.get(&num).is_none_or(|old| matches!(old, XrefEntry::Free { .. })) {
sect.insert(num, e);
}
}
}
Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")),
}
}
oddities.extend(odd.into_iter().map(|num| (off, num)));
for (num, e) in sect {
entries.entry(num).or_insert(e);
}
@ -1061,12 +1016,11 @@ impl Document {
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
trailer.remove(k);
}
Ok((entries, trailer, revisions, oddities))
Ok((entries, trailer, revisions))
}
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]).
fn read_section(&self, off: usize, entries: &mut BTreeMap<u32, XrefEntry>, odd: &mut Vec<u32>) -> Result<(Dict, bool), CosError> {
/// Read one section at `off`, adding entries not already known (newer sections win).
fn read_section(&self, off: usize, entries: &mut BTreeMap<u32, XrefEntry>) -> Result<(Dict, bool), CosError> {
let data = &self.data;
let mut lx = Lexer::new(data, off);
if lx.eat_keyword(b"xref") {
@ -1098,17 +1052,11 @@ impl Document {
(b"f", _) => XrefEntry::Free { next_generation: generation },
_ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
};
// Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0
// is never a real object, so its entry cannot mislead anyone.)
// Offset 0 "in use" entries are a known producer bug: ignore them.
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
if num != 0 {
odd.push(num);
}
continue;
}
if *entries.entry(num).or_insert(entry) != entry {
odd.push(num);
}
entries.entry(num).or_insert(entry);
}
}
}
@ -1162,21 +1110,12 @@ impl Document {
0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
// Reserved types are treated as null references (§7.5.8.3).
_ => {
odd.push(num);
continue;
}
_ => continue, // reserved types are treated as null references (§7.5.8.3)
};
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
if num != 0 {
odd.push(num);
}
continue;
}
if *entries.entry(num).or_insert(entry) != entry {
odd.push(num);
}
entries.entry(num).or_insert(entry);
}
}
Ok((s.dict.clone(), true))
@ -1324,44 +1263,6 @@ mod tests {
out
}
/// Entries the reader sets aside are remembered, so a caller comparing revisions can tell
/// that another reader may resolve the object differently.
#[test]
fn cross_reference_entries_that_are_set_aside_are_recorded() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let plain = Document::open(Arc::new(base.clone())).unwrap();
assert!(plain.xref_oddities().is_empty());
assert!((1..=3).all(|n| plain.xref_entry_is_exact(n)));
let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap();
let third = plain.xref_entry(3).unwrap();
// A later section: object 3 "in use" at offset 0, object 2 listed twice with different
// offsets, and object 0 at offset 0 (the usual producer slip, which names no object).
let mut bytes = base.clone();
let section = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]);
// What the reader resolves is what it resolved before: the first entry it kept.
assert_eq!(doc.xref_entry(3), Some(third));
assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 }));
}
/// An entry that misses its object still resolves (by searching) but is not exact.
#[test]
fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let text = String::from_utf8_lossy(&base).into_owned();
let at = text.find("3 0 obj").unwrap();
let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes();
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec()));
assert!(!doc.xref_entry_is_exact(3));
assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2));
}
#[test]
fn invalid_xref_object_ranges_are_reconstructed() {
// Original in-memory PDF: an intact catalog followed by a damaged xref section.

View File

@ -24,7 +24,7 @@ mod writer;
pub use bytes::Bytes;
pub use document::{Document, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use parser::{Lexer, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
};

View File

@ -11,8 +11,7 @@ use crate::{Bytes, CosError};
const MAX_DEPTH: usize = 128;
/// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1).
pub fn is_whitespace(b: u8) -> bool {
pub(crate) fn is_whitespace(b: u8) -> bool {
matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ')
}

View File

@ -431,15 +431,6 @@ fn write_xref_table(out: &mut Vec<u8>, offsets: &BTreeMap<u32, (u64, u16, bool)>
if include_zero {
all.insert(0, (0, (0, 65535, false)));
}
// A section is one or more subsections. `xref` followed immediately by `trailer` is not a
// table (qpdf: "xref syntax invalid") and is what an incremental save of a classic-table
// file used to append when nothing was edited. A zero-length subsection names no objects,
// so the free list and every in-use entry stay as they were, while a trailer-only change
// (a missing `/ID`, or a caller edit of the trailer) is still written.
if all.is_empty() {
let _ = writeln!(out, "0 0");
return;
}
let mut i = 0;
while i < all.len() {
let start = all[i].0;

View File

@ -135,116 +135,6 @@ fn full_saves_are_allocated_at_their_exact_size() {
}
}
/// A one-page classic-table file with no `/ID`, the case that used to append `xref` and then
/// `trailer` with no subsection (#1339).
fn classic_table() -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R >>",
"<< /Length 0 >>\nstream\n\nendstream",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
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());
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 _ = std::fs::remove_file(&path);
Some(check)
}
fn page(doc: &Document) -> Dict {
let root = doc.resolve(doc.trailer().get(b"Root").unwrap());
let pages = doc.resolve(root.as_dict().unwrap().get(b"Pages").unwrap());
let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
doc.resolve(kids.first().unwrap()).as_dict().cloned().unwrap()
}
fn assert_qpdf_clean(bytes: &[u8]) {
let Some(check) = qpdf_check(bytes) else {
eprintln!("qpdf not installed: skipped");
return;
};
let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr));
assert!(check.status.success() && !report.contains("WARNING"), "{report}");
}
#[test]
fn unchanged_incremental_save_of_a_classic_table_is_a_valid_xref() {
let input = classic_table();
let doc = Document::open(Arc::new(input.clone())).unwrap();
assert!(!doc.is_modified());
assert!(doc.repair_log().is_empty());
assert_eq!(doc.revisions().len(), 1);
assert!(!doc.revisions()[0].is_stream);
let unchanged = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(unchanged.starts_with(&input));
let appended = &unchanged[input.len()..];
assert!(appended.windows(8).any(|w| w == b"xref\n0 0"), "a section with no entries still needs a subsection");
assert!(!appended.windows(13).any(|w| w == b"xref\ntrailer"));
let back = Document::open(Arc::new(unchanged.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
assert_eq!(back.revisions().len(), 2);
assert_eq!(page(&back).name(b"Type"), Some(b"Page".as_slice()));
assert!(back.trailer().get(b"ID").is_some(), "a file without an identifier still receives one");
assert_eq!(hayro_pages(&unchanged), 1);
assert_qpdf_clean(&unchanged);
let mut edited = doc.clone();
edited.update_dict(ObjRef::new(3, 0), |d| d.set(b"Rotate".to_vec(), Object::Int(90))).unwrap();
let edited = write_incremental(&edited, &SaveOptions::default()).unwrap();
let edited_back = Document::open(Arc::new(edited.clone())).unwrap();
assert!(edited_back.repair_log().is_empty(), "{:?}", edited_back.repair_log());
assert_eq!(edited_back.get(ObjRef::new(3, 0)).as_dict().unwrap().int(b"Rotate"), Some(90));
assert_qpdf_clean(&edited);
let stamped = write_incremental(&doc, &SaveOptions { mod_date: Some("D:20260101000000Z".into()), ..SaveOptions::default() }).unwrap();
let stamped_back = Document::open(Arc::new(stamped.clone())).unwrap();
assert!(stamped_back.repair_log().is_empty(), "{:?}", stamped_back.repair_log());
let info = stamped_back.resolve(stamped_back.trailer().get(b"Info").unwrap());
assert_eq!(
info.as_dict().unwrap().get(b"ModDate").and_then(|d| d.as_string()).map(|s| s.bytes.as_slice()),
Some(b"D:20260101000000Z".as_slice())
);
assert_qpdf_clean(&stamped);
}
#[test]
fn incremental_save_keeps_a_trailer_only_edit() {
let input = classic_table();
let mut doc = Document::open(Arc::new(input)).unwrap();
assert!(!doc.is_modified());
doc.trailer_mut().set(b"Custom".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"keep me".to_vec())));
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
assert_eq!(back.trailer().get(b"Custom").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"keep me".as_slice()));
assert_eq!(hayro_pages(&saved), 1);
assert_qpdf_clean(&saved);
}
#[test]
fn qpdf_accepts_object_stream_output() {
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {

View File

@ -15,8 +15,6 @@ pdfcraft-fonts = { workspace = true }
png = "0.18"
tiff = "0.11"
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
# Already in the graph through image; read directly for CMYK JPEG samples, which image converts to RGB.
zune-jpeg = "0.5"
thiserror = { workspace = true }
# Already in the graph through lopdf; used only to read plain-text files (decode_text).
encoding_rs = "0.8"

View File

@ -1,6 +1,6 @@
//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages
//! use, as files. JPEG images are written unchanged; other images, and JPEG images with
//! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour
//! transparency, are decoded and written as PNG with their soft mask, stencil mask or colour
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are
//! reported, never silently left out.
@ -126,24 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let key = if mask { None } else { colour_key(doc, s) };
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case.
let inverted = s
.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
.unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
// a negative.
let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk));
if jpeg && alpha.is_none() && key.is_none() && !as_is_negative {
if jpeg && alpha.is_none() && key.is_none() {
return as_is();
}
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let (samples, bpc) = if jpeg {
// Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an
// indexed one (not valid), is written as is, without its transparency.
// A JPEG decoder gives grey or RGB (CMYK and YCCK are converted); a JPEG that doesn't
// decode, or an indexed one (not valid), is written as is, without its transparency.
let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };
space = decoded;
(samples, 8)
@ -164,10 +153,17 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
if alpha.is_none() {
alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k));
}
if jpeg && alpha.is_none() && !as_is_negative {
if jpeg && alpha.is_none() {
return as_is();
}
let invert = inverted ^ mask; // a stencil mask paints its 0 samples
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case. A JPEG
// decoder has already applied an Adobe JPEG's own inversion.
let invert = s
.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)));
let invert = (invert.unwrap_or(false) && !jpeg) ^ mask; // a stencil mask paints its 0 samples
let max = (1u32 << bpc.min(8)) - 1;
let mut rgb = Vec::with_capacity(w * h * 3);
for px in samples.chunks_exact(n) {
@ -204,13 +200,10 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> {
(w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h))
}
/// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB,
/// with the space they are in.
/// A JPEG's samples (8 bits): grey for a grey image, else RGB, with the space they are in.
fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u8>, Space)> {
match space {
Space::Indexed(..) => return None,
Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)),
_ => {}
if matches!(space, Space::Indexed(..)) {
return None;
}
let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?;
if img.width() as usize != w || img.height() as usize != h {
@ -222,34 +215,6 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
})
}
/// A four-component JPEG's CMYK samples as the page reads them: as stored, not inverted the way
/// a decoder converting to RGB inverts them, with YCCK converted to CMYK.
fn cmyk_jpeg_samples(data: &[u8], w: usize, h: usize) -> Option<Vec<u8>> {
use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
dec.decode_headers().ok()?;
let input = dec.input_colorspace()?;
if !matches!(input, ColorSpace::CMYK | ColorSpace::YCCK) || dec.dimensions()? != (w, h) {
return None;
}
dec.set_options(options.jpeg_set_out_colorspace(input));
let mut px = dec.decode().ok()?;
if px.len() != w.checked_mul(h)?.checked_mul(4)? {
return None;
}
if input == ColorSpace::YCCK {
for p in px.as_chunks_mut::<4>().0 {
let (y, cb, cr) = (f32::from(p[0]), f32::from(p[1]) - 128.0, f32::from(p[2]) - 128.0);
let ink = |rgb: f32| (255.0 - rgb).round().clamp(0.0, 255.0) as u8;
p[0] = ink(y + 1.402 * cr);
p[1] = ink(y - 0.344_136 * cb - 0.714_136 * cr);
p[2] = ink(y + 1.772 * cb);
}
}
Some(px)
}
/// The colour-key `/Mask` (8.9.6.4): a [min max] pair per colour component, in sample values.
fn colour_key(doc: &Document, s: &Stream) -> Option<Vec<(u32, u32)>> {
let m = doc.resolve(s.dict.get(b"Mask")?);

View File

@ -488,21 +488,12 @@ pub enum SourceKind {
/// File extensions Create converts to PDF (images and plain text).
pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"];
/// A classic TIFF or a BigTIFF (version 43), in either byte order.
fn is_tiff(bytes: &[u8]) -> bool {
[b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"].iter().any(|m| bytes.starts_with(*m))
}
/// The GIF headers `embed` decodes, so detection and conversion agree.
fn is_gif(bytes: &[u8]) -> bool {
bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
}
fn is_image(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xFF, 0xD8])
|| bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| is_tiff(bytes)
|| is_gif(bytes)
|| bytes.starts_with(b"II*\0")
|| bytes.starts_with(b"MM\0*")
|| bytes.starts_with(b"GIF8")
// JPEG 2000: a JP2 file or a raw codestream.
|| bytes.starts_with(JP2_SIGNATURE)
|| bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51])
@ -531,11 +522,11 @@ fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
Ok(vec![jpeg(name, bytes)?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if is_tiff(bytes) {
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
tiff_pages(name, bytes)
} else if bytes.starts_with(b"BM") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
} else if is_gif(bytes) {
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
Ok(vec![jpx(name, bytes)?])

View File

@ -408,28 +408,6 @@ fn source_kind_tells_pdfs_images_and_text_apart() {
assert_eq!(source_kind("b.bmp", b"BM"), None);
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None);
assert_eq!(source_kind("", b""), None);
// Only the GIF versions Create decodes count as images, so detection never promises a
// conversion that then fails.
assert_eq!(source_kind("a.gif", b"GIF8x not a gif"), None);
assert_eq!(source_kind("notes.txt", b"GIF8 is a word here"), Some(SourceKind::Text));
}
/// BigTIFF (version 43, used for large scans) is detected and converted like a classic TIFF.
#[test]
fn bigtiff_images_become_pages() {
let mut tif = std::io::Cursor::new(Vec::new());
{
let mut enc = tiff::encoder::TiffEncoder::new_big(&mut tif).unwrap();
let mut im = enc.new_image::<tiff::encoder::colortype::Gray8>(4, 2).unwrap();
im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: 144, d: 1 });
im.write_data(&[128; 8]).unwrap();
}
let tif = tif.into_inner();
assert!(tif.starts_with(b"II+\0"), "the encoder wrote a BigTIFF");
assert_eq!(source_kind("scan.tif", &tif), Some(SourceKind::Image));
let doc = reopen(&from_images(&[("scan.tif".into(), tif)]).unwrap());
assert_eq!(pages(&doc).len(), 1);
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 2.0, 1.0], "4×2 px at 144 dpi");
}
/// A synthetic ICC profile: a 128-byte header (size, device class, colour space, `acsp`) and
@ -743,78 +721,3 @@ fn damaged_masks_and_sizes_never_panic() {
let _ = image_file(&doc, r);
}
}
/// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an
/// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK).
fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec<u8> {
fn put(bits: &mut Vec<bool>, value: u32, n: u32) {
bits.extend((0..n).rev().map(|i| value >> i & 1 == 1));
}
let mut v = vec![0xFF, 0xD8];
v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]);
v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]);
v.extend_from_slice(&[1; 64]);
v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8, 0, 8, 0, 8, 4]);
for id in 1..=4 {
v.extend_from_slice(&[id, 0x11, 0]);
}
// DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0".
v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
v.extend(0..12u8);
v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]);
let mut bits = Vec::new();
for s in samples {
// With every quantiser 1, a flat block's DC coefficient is 8 × (sample − 128).
let dc = (i32::from(s) - 128) * 8;
let category = 32 - dc.unsigned_abs().leading_zeros();
put(&mut bits, category, 4);
put(&mut bits, if dc < 0 { (dc + (1 << category) - 1) as u32 } else { dc as u32 }, category);
put(&mut bits, 0, 1);
}
bits.resize(bits.len().div_ceil(8) * 8, true);
for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) {
v.push(byte);
if byte == 0xFF {
v.push(0);
}
}
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
#[test]
fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() {
// Issue #1640: in a PDF a CMYK JPEG's samples are ink amounts, inverted only by its /Decode,
// while JPEG decoders and image viewers read them inverted (Adobe's convention for files).
// Exported cyan came out red. YCCK samples (Y 179, Cb 171, Cr 1) are cyan ink too.
let inverting = Object::Array([1, 0, 1, 0, 1, 0, 1, 0].map(Object::Int).to_vec());
for (what, samples, transform, decode, masked) in [
("CMYK", [255, 0, 0, 0], 0, None, true),
("YCCK", [179, 171, 1, 0], 2, None, true),
("inverted CMYK with an inverting /Decode", [0, 255, 255, 255], 0, Some(&inverting), true),
("CMYK without a mask", [255, 0, 0, 0], 0, None, false),
("inverted CMYK with an inverting /Decode, without a mask", [0, 255, 255, 255], 0, Some(&inverting), false),
] {
let jpeg = flat_four_component_jpeg(samples, transform);
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut d = image_dict(8, 8, "DeviceCMYK", 8);
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
if let Some(decode) = decode {
d.set(b"Decode".to_vec(), decode.clone());
}
if masked {
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64])));
d.set(b"SMask".to_vec(), Object::Ref(m));
}
Stream::from_raw(d, jpeg.clone())
});
let (ext, rgba) = exported_rgba(&doc);
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{what}: {:?}", &rgba[..4]);
if decode.is_some() && !masked {
// Inverted like an image file expects: still written as is.
assert_eq!((ext, &extract_images(&doc, &[0], 0).images[0].data), ("jpg", &jpeg), "{what}");
}
}
}

View File

@ -15,7 +15,6 @@ pdfcraft-content = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
kurbo = { workspace = true }
# Display order of right-to-left lines in added text, and reading and writing order in Edit text;
# already in the tree via the interface.
unicode-bidi = "0.3"

View File

@ -11,7 +11,9 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi};
use pdfcraft_fonts::{
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, helvetica_width, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi,
};
use unicode_bidi::{Level, ParagraphBidiInfo};
use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged};
@ -65,30 +67,15 @@ impl Family {
(family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique"))
}
/// Advance width of `s` at `size` points, from the published standard-14 metrics.
///
/// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]),
/// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722).
pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 {
self.std14(bold, italic).text_width(s, size)
}
/// The standard-14 face this family means at the given style.
pub fn std14(self, bold: bool, italic: bool) -> Std14 {
match (self, bold, italic) {
(Family::Courier, false, false) => Std14::Courier,
(Family::Courier, true, false) => Std14::CourierBold,
(Family::Courier, false, true) => Std14::CourierOblique,
(Family::Courier, true, true) => Std14::CourierBoldOblique,
(Family::Times, false, false) => Std14::TimesRoman,
(Family::Times, true, false) => Std14::TimesBold,
(Family::Times, false, true) => Std14::TimesItalic,
(Family::Times, true, true) => Std14::TimesBoldItalic,
(Family::Helvetica, false, false) => Std14::Helvetica,
(Family::Helvetica, true, false) => Std14::HelveticaBold,
(Family::Helvetica, false, true) => Std14::HelveticaOblique,
(Family::Helvetica, true, true) => Std14::HelveticaBoldOblique,
}
/// Approximate advance width of `s` (standard-14 metrics are not bundled: Helvetica widths,
/// scaled for Times; Courier is monospaced).
pub fn width(self, s: &str, size: f64, bold: bool) -> f64 {
let w = match self {
Family::Courier => s.chars().count() as f64 * 0.6 * size,
Family::Times => helvetica_width(s, size) * 0.9,
Family::Helvetica => helvetica_width(s, size),
};
if bold { w * 1.05 } else { w }
}
}
@ -253,7 +240,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
@ -284,7 +271,7 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, Edit
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))),
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
let text = c.to_string();
let w = t.family.width(&text, t.size, t.bold, t.italic);
let w = t.family.width(&text, t.size, t.bold);
(text, w)
})),
}
@ -427,7 +414,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
let width = pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic),
Piece::Latin(s) => t.family.width(s, t.size, t.bold),
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
})
.sum();
@ -437,7 +424,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic))
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold))
}
fn arabic_error(e: GlyphError, text: &str) -> EditError {
@ -549,10 +536,6 @@ fn draw_laid_shaped(
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
// Shaped glyphs are painted in visual order. Keep the original line's typing order
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
let spaces = line.matches(' ').count();
// Justified: the spaces of every line but the last stretch to fill the box.
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
@ -573,7 +556,7 @@ fn draw_laid_shaped(
out.extend(literal(&win_ansi(s)));
out.extend_from_slice(b" Tj\n");
current = Some(latin.as_str());
pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64;
pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64;
}
Piece::Shaped(clusters) => {
for c in clusters {
@ -593,7 +576,6 @@ fn draw_laid_shaped(
}
}
}
out.extend_from_slice(b"EMC\n");
}
out.extend_from_slice(b"ET\n");
Ok(())
@ -726,7 +708,7 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
for (i, line) in lines(t).iter().enumerate() {
let w = t.family.width(line, t.size, t.bold, t.italic);
let w = t.family.width(line, t.size, t.bold);
let x = match t.align {
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
@ -953,7 +935,7 @@ fn validate(c: &Content) -> Result<(), EditError> {
let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {
t.text.split('\n').find_map(|line| {
line.chars()
.find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c)))
.find(|c| !shaped_arabic(*c) && !(is_thai(*c) && pdfcraft_fonts::thai_has(*c)) && !pdfcraft_fonts::win_ansi_encodable(*c))
})
} else {
first_undrawable(t)

View File

@ -6,14 +6,9 @@ use std::cell::Cell;
use super::*;
/// Regression fingerprint for the small-item fixture: drawing bytes, font map and document
/// objects, with generated streams decoded so Flate backend selection cannot change the hash.
// Re-pinned when the standard-14 metrics became exact (F1). The Latin run in the fingerprinted
// item is "Hi " in Helvetica 12pt: the old character-class guess called H 680, i 260 and space
// 260 (14.4pt), where the published metrics are 722, 222 and 278 (14.664pt). The Arabic clusters
// that follow therefore start 0.264pt further right, which is the whole point of F1 -- the glyphs
// now land where the font says they do. Nothing about the Arabic path changed.
const SMALL_ITEM_FINGERPRINT: u64 = 9_200_256_185_091_160_371;
/// The fingerprint of an item below the old limit, from the code before the change: its content
/// stream, its new fonts and every object of the document.
const SMALL_ITEM_FINGERPRINT: u64 = 14_135_892_979_695_233_112;
/// A cluster that shows one character with the single glyph `id`, at `advance` em.
fn cluster(id: u32, advance: f64) -> ShapedCluster {
@ -48,25 +43,6 @@ fn snapshot(doc: &Document) -> Vec<u8> {
bytes
}
/// Serialize generated objects with decoded stream bytes so the golden does not depend on which
/// Flate backend Cargo's unified dependency features select for the test binary. Only the raw
/// bytes (and so `/Length`, which the writer takes from them) are normalised: `/Filter` and
/// `/DecodeParms` stay in the hash, so a change that stops compressing generated streams, or
/// switches filters, still fails the golden.
fn golden_snapshot(doc: &Document) -> Vec<u8> {
let mut bytes = Vec::new();
for num in doc.object_numbers() {
let mut obj = (*doc.get(ObjRef { num, generation: doc.generation(num) })).clone();
if let Object::Stream(ref mut stream) = obj {
let decoded = stream.decoded_strict().expect("generated stream decodes");
stream.raw = decoded.into();
}
pdfcraft_cos::serialize(&obj, &mut bytes);
bytes.push(b'\n');
}
bytes
}
/// The fonts an item drew, in creation order, with their glyph counts.
fn fonts_of(doc: &Document, fonts: &Dict) -> Vec<(Vec<u8>, usize)> {
fonts
@ -157,8 +133,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
assert_eq!(fonts.len(), 1);
// The injected outlines are fixed; only the descriptor's family comes from craft-fonts.
// Check that metadata separately, then normalize those two fields to the original golden.
// ActualText is new metadata: check each line's value and remove only its added wrappers
// before hashing. Drawing bytes and font objects still match the original golden exactly.
// Every drawing byte and every other object field must still match, in both build modes.
let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |face| face.family);
for (_, font) in fonts.iter() {
let descriptor = doc.dict(font).unwrap().get(b"FontDescriptor").and_then(Object::as_ref).unwrap();
@ -172,17 +147,9 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
.unwrap();
}
let mut bytes = out.clone();
for (line, ..) in &laid {
let hex: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
let header = format!("/Span <</ActualText <FEFF{hex}>>> BDC\n").into_bytes();
let start = bytes.windows(header.len()).position(|w| w == header).expect("each line has its logical text");
bytes.drain(start..start + header.len());
let end = bytes.windows(4).position(|w| w == b"EMC\n").expect("each ActualText span closes");
bytes.drain(end..end + 4);
}
pdfcraft_cos::serialize(&Object::Dict(fonts.clone()), &mut bytes);
bytes.extend(golden_snapshot(&doc));
assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "small-item output changed");
bytes.extend(snapshot(&doc));
assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "output changed; the golden is the code before the sharding change");
}
#[test]

View File

@ -168,9 +168,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw fails here and is kept below, so the comment is
// not deleted with nothing drawn in its place. (Text the standard fonts can't draw
// was refused before anything changed: `undrawn_on`.)
// A subtype this build cannot draw only fails here; it is still removed below. (Text
// the standard fonts can't draw was refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
for (k, entry) in list.iter().enumerate() {
@ -186,7 +185,6 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
continue;
}
let rect = nums(doc, d.get(b"Rect")).filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
let mut drew = false;
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
if let Some(m) = placement(doc, &form, rect) {
@ -194,15 +192,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
xobjects.set(name.into_bytes(), Object::Ref(ap));
drawn += 1;
drew = true;
}
}
// A comment whose ink was not written into the page stays. Removing it would drop
// the annotation (a cloudy square this builder cannot draw, for example) with nothing
// left on the page. Widgets are form fields: flattening still removes them.
if !widget && !drew {
continue;
}
match entry.as_ref() {
Some(r) => {
gone.insert(r);
@ -258,49 +249,33 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
})?,
}
}
// A field whose widgets are on no page has nothing to draw, but flattening every field of the
// document must still take it out of the form, or other software keeps seeing one (#1470).
let orphans = matches!(which, Which::Comments { fields: true, .. }) && (0..all.len()).all(|i| pages.contains(&i));
if !removed_widgets.is_empty() || orphans {
let on_page: HashSet<ObjRef> = page_list(doc)
.iter()
.filter_map(|p| p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()))
.flatten()
.filter_map(|e| e.as_ref())
.collect();
prune_fields(doc, &removed_widgets, orphans.then_some(&on_page))?;
if !removed_widgets.is_empty() {
prune_fields(doc, &removed_widgets)?;
}
Ok(drawn)
}
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With
/// `on_page`, also the widgets (fields without kids) that are on no page.
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>) -> Result<(), EditError> {
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets.
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>) -> Result<(), EditError> {
let Some(root) = doc.root() else { return Ok(()) };
let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
let af_ref = af.as_ref();
let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default();
let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
// Returns whether the node survives.
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>, depth: usize) -> bool {
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, depth: usize) -> bool {
if widgets.contains(&r) || depth > 64 {
return depth > 64;
}
let Some(d) = doc.get(r).as_dict().cloned() else { return true };
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else {
return on_page.is_none_or(|p| p.contains(&r));
};
let survivors: Vec<Object> =
kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect();
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { return true };
let survivors: Vec<Object> = kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, depth + 1))).cloned().collect();
if survivors.len() != kids.len() {
let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone())));
}
!survivors.is_empty()
}
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect();
if survivors.len() == fields.len() && widgets.is_empty() {
return Ok(()); // only looked for fields on no page, and there were none
}
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect();
af_dict.set(b"Fields".to_vec(), Object::Array(survivors));
match af_ref {
Some(r) => doc.set(r, Object::Dict(af_dict)),

View File

@ -3,10 +3,8 @@
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
//! or delete. Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use std::sync::Arc;
use crate::EditError;
@ -15,11 +13,6 @@ use crate::EditError;
pub struct PageImage {
/// Its box in user space.
pub rect: [f64; 4],
/// Visible frame after page and analytic path bounds. Hit testing also respects curve
/// interiors, winding rules and holes, rather than intercepting their empty areas.
pub visible_rect: Option<[f64; 4]>,
/// Current page/path clip in user space. A private crop transforms with its image.
pub clip_rect: Option<[f64; 4]>,
/// The placement: image unit square or Form coordinates → user space.
pub matrix: [f64; 6],
/// The XObject resource name and object.
@ -34,41 +27,6 @@ pub struct PageImage {
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
draw_matrix: [f64; 6],
clips: Vec<ClipPath>,
}
impl PageImage {
/// Whether a user-space point belongs to the placed artwork and every active clip.
pub fn contains(&self, point: [f64; 2]) -> bool {
if !point.iter().all(|v| v.is_finite()) {
return false;
}
let Some(bounds) = self.visible_rect else {
return false;
};
if point[0] < bounds[0] || point[0] > bounds[2] || point[1] < bounds[1] || point[1] > bounds[3] {
return false;
}
if !self.is_form {
let Some(inverse) = Matrix(self.matrix).invert() else {
return false;
};
let (x, y) = inverse.apply(point[0], point[1]);
if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
return false;
}
}
self.clips.iter().all(|clip| {
let winding = clip.path.winding(Point::new(point[0], point[1]));
if clip.even_odd { winding % 2 != 0 } else { winding != 0 }
})
}
}
#[derive(Clone, Debug, PartialEq)]
struct ClipPath {
path: Arc<BezPath>,
even_odd: bool,
}
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@ -101,113 +59,6 @@ fn xobjects(doc: &Document, page: &Dict) -> Dict {
.unwrap_or_default()
}
fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
}
#[derive(Clone)]
struct Placement {
matrix: Matrix,
clip: [f64; 4],
clips: Vec<ClipPath>,
}
/// Path coordinates are transformed when constructed; q/Q save clips, not the current path.
struct ImageState {
placement: Placement,
stack: Vec<Placement>,
path: BezPath,
pending_clip: Option<bool>,
}
impl ImageState {
fn new(matrix: Matrix, clip: [f64; 4], clips: Vec<ClipPath>) -> Self {
Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None }
}
fn point(&self, x: f64, y: f64) -> Point {
let (x, y) = self.placement.matrix.apply(x, y);
Point::new(x, y)
}
fn apply(&mut self, op: &Op) -> Result<(), EditError> {
if matches!(op.op.as_slice(), b"cm" | b"m" | b"l" | b"c" | b"v" | b"y" | b"re")
&& op.operands.iter().any(|v| v.as_f64().is_some_and(|v| !v.is_finite() || v.abs() > 1e12))
{
return Err(EditError::Invalid("the image path contains invalid coordinates".into()));
}
if self.path.elements().len() > 16384 || self.stack.len() > 4096 || self.placement.clips.len() > 256 {
return Err(EditError::Invalid("the image clip or graphics state exceeds its complexity budget".into()));
}
match op.op.as_slice() {
b"q" => self.stack.push(self.placement.clone()),
b"Q" => {
if let Some(saved) = self.stack.pop() {
self.placement = saved;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
self.placement.matrix = Matrix(m).then(&self.placement.matrix);
}
}
b"m" => {
if let Some([x, y]) = op.nums::<2>() {
self.path.move_to(self.point(x, y));
}
}
b"l" => {
if let Some([x, y]) = op.nums::<2>() {
self.path.line_to(self.point(x, y));
}
}
b"c" => {
if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() {
self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3));
}
}
b"v" => {
if let Some([x1, y1, x2, y2]) = op.nums::<4>()
&& let Some(current) = self.path.elements().last().and_then(|e| e.end_point())
{
self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2));
}
}
b"y" => {
if let Some([x1, y1, x2, y2]) = op.nums::<4>() {
let end = self.point(x2, y2);
self.path.curve_to(self.point(x1, y1), end, end);
}
}
b"h" => self.path.close_path(),
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
self.path.move_to(self.point(x, y));
self.path.line_to(self.point(x + w, y));
self.path.line_to(self.point(x + w, y + h));
self.path.line_to(self.point(x, y + h));
self.path.close_path();
}
}
b"W" => self.pending_clip = Some(false),
b"W*" => self.pending_clip = Some(true),
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
if let Some(even_odd) = self.pending_clip.take() {
// Filling/clipping implicitly closes open subpaths. BezPath's winding
// follows the same rule, and its bounds use cubic extrema, not controls.
let bounds = self.path.bounding_box();
self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]);
self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd });
}
self.path = BezPath::new();
}
_ => {}
}
Ok(())
}
}
/// The images and Form artwork page `page` (0-based) draws, in drawing order.
/// Forms stay intact: no traversal or rewriting of their internal objects is needed.
pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
@ -215,70 +66,76 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
let xo = xobjects(doc, &p.dict);
let mut out = Vec::new();
// The graphics state carries over from one stream to the next.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut ctm = Matrix::IDENTITY;
let mut stack = Vec::new();
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
for (i, op) in parse(&data).ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
match op.op.as_slice() {
b"q" => stack.push(ctm),
b"Q" => ctm = stack.pop().unwrap_or(ctm),
b"cm" => {
if let Some(m) = op.nums::<6>() {
ctm = Matrix(m).then(&ctm);
}
}
b"Do" => {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
}
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
continue;
};
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
});
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
_ => {}
}
}
}
@ -296,73 +153,65 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
path: Vec<ObjRef>,
visits: usize,
}
fn walk(
doc: &Document,
data: &[u8],
resources: &Dict,
st: &mut ImageState,
stream: usize,
forms: &mut Forms,
out: &mut Vec<PageImage>,
) -> Result<(), EditError> {
struct State {
ctm: Matrix,
stack: Vec<Matrix>,
}
fn walk(doc: &Document, data: &[u8], resources: &Dict, st: &mut State, stream: usize, forms: &mut Forms, out: &mut Vec<PageImage>) {
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let (ctm, stack) = (&mut st.ctm, &mut st.stack);
for (i, op) in parse(data).ops.iter().enumerate() {
st.apply(op)?;
let ctm = st.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) {
if let Some(r) = form.obj {
forms.path.push(r);
match op.op.as_slice() {
b"q" => stack.push(*ctm),
b"Q" => *ctm = stack.pop().unwrap_or(*ctm),
b"cm" => {
if let Some(m) = op.nums::<6>() {
*ctm = Matrix(m).then(ctm);
}
let bounds =
xo.get(name).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then(
|b| {
let a = b.as_array()?;
let [x0, y0, x1, y1] = a.as_slice() else { return None };
Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?]))
},
);
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
if form.obj.is_some() {
forms.path.pop();
}
b"Do" => {
let Some(name) = op.name(0) else { continue };
if let Some(form) = crate::text::form_call(doc, resources, name, *ctm, &forms.path, &mut forms.visits) {
if let Some(r) = form.obj {
forms.path.push(r);
}
let mut inner = State { ctm: form.ctm, stack: Vec::new() };
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out);
if form.obj.is_some() {
forms.path.pop();
}
continue;
}
continue;
let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.name(b"Subtype") != Some(b"Image") {
continue;
}
out.push(PageImage {
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
matrix: ctm.0,
name: String::from_utf8_lossy(name).into_owned(),
object: Some(r),
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
stream,
op: i,
draw_matrix: ctm.0,
});
}
let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.name(b"Subtype") != Some(b"Image") {
continue;
}
out.push(PageImage {
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip),
clip_rect: Some(st.placement.clip),
matrix: ctm.0,
name: String::from_utf8_lossy(name).into_owned(),
object: Some(r),
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
stream,
op: i,
draw_matrix: ctm.0,
clips: st.placement.clips.clone(),
});
_ => {}
}
}
Ok(())
}
let p = page_of(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut st = State { ctm: Matrix::IDENTITY, stack: Vec::new() };
let mut forms = Forms { path: Vec::new(), visits: 0 };
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out);
}
Ok(out)
}
@ -435,14 +284,33 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let all = streams(doc, &p.dict);
let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let mut ops = parse(&data).ops;
if ops.get(img.op).is_none() {
return Err(EditError::Invalid("the image operation is missing".into()));
}
let original = ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let mut resources = None;
let mut clip_move = None;
let replacement: Vec<Op> = match change {
ImageChange::Delete => Vec::new(),
ImageChange::Transform(t) => {
return transform_image(doc, &p, &all, &img, *t);
if !t.iter().all(|v| v.is_finite()) {
return Err(EditError::Invalid("the image transform must be finite".into()));
}
// New CTM = placement × T; as a `cm` before the Do: M = P · T · P⁻¹.
let pm = Matrix(img.draw_matrix);
let inv = pm.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let m = pm.then(&Matrix(*t)).then(&inv);
if !m.0.iter().all(|v| v.is_finite()) {
return Err(EditError::Invalid("the image transform is too large".into()));
}
// `q re W n cm Do Q` clips only this image: move the clip with it instead of leaving it behind.
if let Some((at, cm)) = exclusive_clip(&ops, img.op) {
if let Some(c) = clip_matrix(Matrix(img.draw_matrix), cm, Matrix(*t)).filter(|c| c.0.iter().all(|v| v.is_finite())) {
clip_move = Some((at, Op::new("cm", c.0.iter().map(|v| pdfcraft_content::num(*v)).collect())));
vec![original]
} else {
return Err(EditError::Invalid("the image transform is too large".into()));
}
} else {
vec![Op::new("q", vec![]), Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()), original, Op::new("Q", vec![])]
}
}
ImageChange::Replace(r) => {
if img.is_form {
@ -460,6 +328,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
}
};
ops.splice(img.op..=img.op, replacement);
if let Some((at, cm)) = clip_move {
ops.insert(at, cm);
}
let mut dict = match &*doc.resolve(&obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
@ -491,140 +362,6 @@ pub(crate) fn selectable_images(
.collect())
}
/// Find the saved graphics-state scope containing just this image and its crop. Applying
/// the transform before that scope moves the actual clipping path as well as the pixels.
/// A shared clip must never be moved along with unrelated artwork or text.
fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usize, Matrix)>, EditError> {
struct Scope {
start: usize,
matrix: Matrix,
inherited_clip: bool,
inherited_path: bool,
selected: bool,
other_paint: bool,
}
let mut stack: Vec<Scope> = Vec::new();
let mut matrix = Matrix::IDENTITY;
let mut clipped = false;
let mut path = false;
let mut pending_clip = false;
let mut text = false;
let mut at_image = None;
let mut isolated = None;
for (index, op) in ops.iter().enumerate() {
match op.op.as_slice() {
b"q" => {
if stack.len() >= 256 {
return Err(EditError::Invalid("the image graphics state is too deeply nested to transform".into()));
}
stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false });
}
b"Q" => {
if let Some(scope) = stack.pop() {
if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path {
// Prefer the innermost scope that contains every active clipping path.
isolated.get_or_insert((scope.start, index, scope.matrix));
}
matrix = scope.matrix;
clipped = scope.inherited_clip;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
matrix = Matrix(m).then(&matrix);
}
}
b"BT" => text = true,
b"ET" => text = false,
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true,
b"W" | b"W*" => pending_clip = true,
_ => {}
}
if index == target {
if text {
return Err(EditError::Invalid("an image inside a text object cannot be transformed".into()));
}
at_image = Some((matrix, clipped));
for scope in &mut stack {
scope.selected = true;
}
} else if matches!(
op.op.as_slice(),
b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*"
) {
for scope in &mut stack {
scope.other_paint = true;
}
}
if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") {
clipped |= pending_clip;
pending_clip = false;
path = false;
}
}
let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
if !clipped {
return Ok(Some((target, target, placement)));
}
Ok(isolated)
}
fn transform_image(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
image: &PageImage,
transform: [f64; 6],
) -> Result<(), EditError> {
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
}
let mut offset = 0_usize;
for (_, data) in streams.iter().take(image.stream) {
offset = offset
.checked_add(data.len())
.and_then(|n| n.checked_add(1))
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
}
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let target = ops
.iter()
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let local = placement.then(&Matrix(transform)).then(&inverse);
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform is too large".into()));
}
let changed = crate::text::splice(&pieces, &ops, |index| {
if index == start {
let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())];
if start == end {
out.extend(ops.get(index).cloned());
out.push(Op::new("Q", vec![]));
return (out, false);
}
(out, true)
} else if index == end {
let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect();
out.push(Op::new("Q", vec![]));
(out, false)
} else {
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {

View File

@ -1,258 +0,0 @@
//! Visual and logical order for Indic scripts in Edit text (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Malayalam).
//!
//! Legacy PDF producers often output glyphs in *visual* display order when using fonts without
//! OpenType GSUB layout tables. In visual order, pre-base vowel signs (such as Devanagari short 'i'
//! `ि` U+093F) appear in the stream *before* the consonant cluster they modify, matching their visual
//! placement to the left of the glyph.
//!
//! Standard Unicode text is *logical* (typing order): the pre-base vowel sign follows the consonant
//! cluster. The editor presents and edits text in logical order. When a source font is flagged with
//! `visual_order`, [`to_logical`] converts extracted stream text to logical order for editing, and
//! [`to_visual`] converts logical edits back into the visual glyph sequence the font expects.
/// Whether `c` is an Indic consonant across covered scripts.
pub fn is_indic_consonant(c: char) -> bool {
matches!(
c,
'\u{0915}'..='\u{0939}' | '\u{0958}'..='\u{095F}' | '\u{0978}'..='\u{097F}' // Devanagari
| '\u{0995}'..='\u{09B9}' | '\u{09DC}'..='\u{09DF}' // Bengali
| '\u{0A15}'..='\u{0A39}' | '\u{0A59}'..='\u{0A5E}' // Gurmukhi
| '\u{0A95}'..='\u{0AB9}' // Gujarati
| '\u{0B15}'..='\u{0B39}' | '\u{0B5C}'..='\u{0B5F}' // Oriya
| '\u{0B95}'..='\u{0BB9}' // Tamil
| '\u{0C15}'..='\u{0C39}' | '\u{0C58}'..='\u{0C5A}' // Telugu
| '\u{0C95}'..='\u{0CB9}' | '\u{0CDE}' // Kannada
| '\u{0D15}'..='\u{0D3A}' // Malayalam
)
}
/// Whether `c` is an Indic nukta (diacritic dot modifying a consonant).
pub fn is_indic_nukta(c: char) -> bool {
matches!(
c,
'\u{093C}' // Devanagari
| '\u{09BC}' // Bengali
| '\u{0A3C}' // Gurmukhi
| '\u{0ABC}' // Gujarati
| '\u{0B3C}' // Oriya
)
}
/// Whether `c` is an Indic virama (halant / vowel killer).
pub fn is_indic_virama(c: char) -> bool {
matches!(
c,
'\u{094D}' // Devanagari
| '\u{09CD}' // Bengali
| '\u{0A4D}' // Gurmukhi
| '\u{0ACD}' // Gujarati
| '\u{0B4D}' // Oriya
| '\u{0BCD}' // Tamil
| '\u{0C4D}' // Telugu
| '\u{0CCD}' // Kannada
| '\u{0D4D}' // Malayalam
)
}
/// Whether `c` is an Indic pre-base vowel sign that appears visually to the left of its consonant.
pub fn is_indic_pre_base_vowel(c: char) -> bool {
matches!(
c,
'\u{093F}' // Devanagari short I
| '\u{09BF}' | '\u{09C7}' | '\u{09C8}' // Bengali short I, E, AI
| '\u{0A3F}' // Gurmukhi short I
| '\u{0ABF}' // Gujarati short I
| '\u{0B3F}' // Oriya short I
| '\u{0BC6}' | '\u{0BC7}' | '\u{0BC8}' // Tamil E, EE, AI
| '\u{0D46}' | '\u{0D47}' | '\u{0D48}' // Malayalam E, EE, AI
)
}
/// Modifiers that attach to an Indic vowel sign (such as candrabindu, anusvara, or visarga).
fn is_indic_vowel_modifier(c: char) -> bool {
matches!(
c,
'\u{0901}'..='\u{0903}' // Devanagari candrabindu, anusvara, visarga
| '\u{0981}'..='\u{0983}' // Bengali
| '\u{0A01}'..='\u{0A03}' // Gurmukhi
| '\u{0A81}'..='\u{0A83}' // Gujarati
| '\u{0B01}'..='\u{0B03}' // Oriya
)
}
/// The character at `i`, if any.
fn at(chars: &[char], i: usize) -> Option<char> {
chars.get(i).copied()
}
/// The end of the consonant cluster starting at `start` (consonants with their nuktas, joined by
/// viramas and optional ZWNJ/ZWJ); `start` itself when no consonant starts there.
fn cluster_end(chars: &[char], start: usize) -> usize {
let mut end = start;
while at(chars, end).is_some_and(is_indic_consonant) {
end += 1;
if at(chars, end).is_some_and(is_indic_nukta) {
end += 1;
}
if at(chars, end).is_some_and(is_indic_virama) {
end += 1;
if at(chars, end).is_some_and(|c| matches!(c, '\u{200C}' | '\u{200D}')) {
end += 1;
}
if at(chars, end).is_some_and(is_indic_consonant) {
continue;
}
}
break;
}
end
}
/// The end of the vowel-modifier run (candrabindu, anusvara, visarga) starting at `start`.
fn modifiers_end(chars: &[char], start: usize) -> usize {
let mut end = start;
while at(chars, end).is_some_and(is_indic_vowel_modifier) {
end += 1;
}
end
}
/// Convert Indic text from visual glyph order (pre-base vowels before their consonant cluster)
/// to standard Unicode logical order.
pub fn to_logical(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<char> = Vec::with_capacity(chars.len());
let mut i = 0;
while let Some(ch) = at(&chars, i) {
if is_indic_pre_base_vowel(ch) {
let v_end = modifiers_end(&chars, i + 1);
let c_end = cluster_end(&chars, v_end);
if c_end > v_end {
out.extend_from_slice(chars.get(v_end..c_end).unwrap_or_default());
out.extend_from_slice(chars.get(i..v_end).unwrap_or_default());
i = c_end;
continue;
}
}
out.push(ch);
i += 1;
}
out.into_iter().collect()
}
/// Convert Indic text from standard Unicode logical order to visual display order for fonts
/// that expect pre-base vowels before their consonant cluster.
pub fn to_visual(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<char> = Vec::with_capacity(chars.len());
let mut i = 0;
while let Some(ch) = at(&chars, i) {
if is_indic_consonant(ch) {
let c_end = cluster_end(&chars, i);
if at(&chars, c_end).is_some_and(is_indic_pre_base_vowel) {
let v_end = modifiers_end(&chars, c_end + 1);
out.extend_from_slice(chars.get(c_end..v_end).unwrap_or_default());
out.extend_from_slice(chars.get(i..c_end).unwrap_or_default());
i = v_end;
} else {
out.extend_from_slice(chars.get(i..c_end).unwrap_or_default());
i = c_end;
}
continue;
}
out.push(ch);
i += 1;
}
out.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_words_roundtrip() {
// किताब (kitab): k + i + t + aa + b
let visual = "\u{093F}\u{0915}\u{0924}\u{093E}\u{092C}";
let logical = "\u{0915}\u{093F}\u{0924}\u{093E}\u{092C}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
// दिन (din): d + i + n
let visual_din = "\u{093F}\u{0926}\u{0928}";
let logical_din = "\u{0926}\u{093F}\u{0928}";
assert_eq!(to_logical(visual_din), logical_din);
assert_eq!(to_visual(logical_din), visual_din);
}
#[test]
fn decomposed_nukta_with_pre_base_vowel() {
// Decomposed ड़ि: DDA (U+0921) + NUKTA (U+093C) + Short I (U+093F)
let logical = "\u{0921}\u{093C}\u{093F}";
let visual = "\u{093F}\u{0921}\u{093C}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
// Decomposed ढ़ि: DDHA (U+0922) + NUKTA (U+093C) + Short I (U+093F)
let logical_ddhi = "\u{0922}\u{093C}\u{093F}";
let visual_ddhi = "\u{093F}\u{0922}\u{093C}";
assert_eq!(to_logical(visual_ddhi), logical_ddhi);
assert_eq!(to_visual(logical_ddhi), visual_ddhi);
}
#[test]
fn precomposed_nukta_with_pre_base_vowel() {
// Precomposed ड़ि (U+095C + U+093F)
let logical = "\u{095C}\u{093F}";
let visual = "\u{093F}\u{095C}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
}
#[test]
fn full_words_with_nukta_and_pre_base_vowel() {
// बढ़िया (badhiya): ba + ddha + nukta + short_i + ya + aa
let logical = "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}";
let visual = "\u{092C}\u{093F}\u{0922}\u{093C}\u{092F}\u{093E}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
// खिड़की (khirki): kha + short_i + dda + nukta + ka + long_ii
let logical_khirki = "\u{0916}\u{093F}\u{0921}\u{093C}\u{0915}\u{0940}";
let visual_khirki = "\u{093F}\u{0916}\u{0921}\u{093C}\u{0915}\u{0940}";
assert_eq!(to_logical(visual_khirki), logical_khirki);
assert_eq!(to_visual(logical_khirki), visual_khirki);
}
#[test]
fn complex_conjuncts_with_pre_base_vowel() {
// स्थिति (sthiti): sa + virama + tha + short_i + ta + short_i
let logical = "\u{0938}\u{094D}\u{0925}\u{093F}\u{0924}\u{093F}";
let visual = "\u{093F}\u{0938}\u{094D}\u{0925}\u{093F}\u{0924}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
}
#[test]
fn other_indic_scripts_pre_base_vowels() {
// Bengali কি (ki): KA (U+0995) + I (U+09BF)
let b_logical = "\u{0995}\u{09BF}";
let b_visual = "\u{09BF}\u{0995}";
assert_eq!(to_logical(b_visual), b_logical);
assert_eq!(to_visual(b_logical), b_visual);
// Gurmukhi ਕਿ (ki): KA (U+0A15) + I (U+0A3F)
let g_logical = "\u{0A15}\u{0A3F}";
let g_visual = "\u{0A3F}\u{0A15}";
assert_eq!(to_logical(g_visual), g_logical);
assert_eq!(to_visual(g_logical), g_visual);
}
#[test]
fn non_indic_text_is_untouched() {
for s in ["", "Hello world!", "PDF 123", "日本語テキスト", "123.45"] {
assert_eq!(to_logical(s), s);
assert_eq!(to_visual(s), s);
}
}
}

View File

@ -19,8 +19,7 @@ use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum EditError {
/// Holds the 0-based index; the message names the 1-based page a user counts (#1550).
#[error("page {} does not exist", .0.saturating_add(1))]
#[error("page {0} does not exist")]
NoSuchPage(usize),
#[error("{0}")]
Invalid(String),
@ -625,12 +624,8 @@ pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre};
pub mod bidi;
pub mod indic;
pub mod text;
pub use text::{
BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks,
text_lines,
};
pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines};
pub mod added;
pub use added::{
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers,

View File

@ -81,10 +81,9 @@ pub fn editable_objects(doc: &Document, page: usize) -> Result<Vec<EditableObjec
});
}
for (index, image) in crate::images::selectable_images(doc, page, &excluded)? {
let Some(rect) = image.visible_rect else { continue };
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Image, index },
rect,
rect: image.rect,
content_type: if image.is_form { "form" } else { "image" },
text: None,
});

View File

@ -1,6 +1,6 @@
use std::sync::Arc;
use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental};
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
use super::*;
@ -179,7 +179,6 @@ fn invalid_requests_change_nothing() {
add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, false),
Err(EditError::NoSuchPage(9))
);
assert_eq!(EditError::NoSuchPage(9).to_string(), "page 10 does not exist", "the message counts pages from 1 (#1550)");
assert!(!doc.is_modified());
}
@ -215,37 +214,6 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
}
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
#[test]
fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
let page = stream("", " ");
let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream";
let case = |extra: &str, drawn: usize, left: bool| {
let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) {extra} >>");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>",
&annot,
&page,
form,
]);
let before = streams(&doc, 0);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn);
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert_eq!(p.dict.contains(b"Annots"), left);
if drawn == 0 {
assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment");
}
};
case("/BE << /S /C /I 2 >>", 0, true);
case("/BE << /S /S >>", 1, false);
case("/BE << /S /C /I 2 >> /F 2", 0, true);
case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false);
}
/// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must
/// generate that appearance and bake it into the page, not delete a comment that shows an icon.
#[test]
@ -266,34 +234,6 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() {
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
}
/// #1470: a form field whose widget is on no page (left behind by page extraction or combining)
/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`,
/// or other software keeps treating the flattened file as a form. A hidden widget on a page is
/// left alone, as before.
#[test]
fn flattening_fields_drops_the_ones_on_no_page() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (on_page) /Rect [10 10 150 40] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 /AP << /N 10 0 R >> >>",
"<< /FT /Tx /T (parent) /Kids [9 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field on the page is drawn");
let doc = reopen(&doc);
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap());
let left: Vec<pdfcraft_cos::ObjRef> = fields.as_array().unwrap().iter().filter_map(|f| f.as_ref()).collect();
assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays");
}
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
/// appearance whose text Helvetica can't draw (another program's), and a comment or form field
/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew
@ -1263,89 +1203,6 @@ fn missing_glyphs_preserve_source_style_in_fallback() {
assert!(content.contains("/PCEdHelveticaBoldOblique 12 Tf"), "{content}");
}
#[test]
fn a_subset_without_a_space_glyph_keeps_its_font_when_editing_words() {
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "ab ba");
let edit = if paragraph { text::replace_block(&mut doc, 0, 0, "ba ab ba") } else { text::replace_line(&mut doc, 0, 0, "ba ab ba") };
assert_eq!(edit.unwrap().substituted, None);
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, "ba ab ba");
assert_eq!(lines[0].font, "F2");
assert!((lines[0].rect[2] - lines[0].rect[0]) > 3.0 * 12.0);
}
}
#[test]
fn spaces_in_another_subset_do_not_split_a_line() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Hello world");
text::replace_block(&mut doc, 0, 0, "Goodbye").unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "Goodbye");
}
#[test]
fn editor_background_removes_glyphs_but_preserves_following_text_advance() {
for show in ["(Hello) Tj", "[(He) -20 (llo)] TJ", "(Hello) '", "2 1 (Hello) \""] {
let mut doc = text_page(&format!("BT /F1 12 Tf 14 TL 72 700 Td {show} [-3000] TJ /F2 12 Tf (ab) Tj ET"));
let before = text::text_lines(&doc, 0).unwrap();
assert_eq!(before.len(), 2);
text::clear_block(&mut doc, 0, 0).unwrap();
let after = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(after.len(), 1, "{show}");
assert_eq!(after[0].text, "ab");
for (a, b) in after[0].rect.iter().zip(before[1].rect) {
assert!((a - b).abs() < 1e-6, "unselected text moved: {show}");
}
}
}
#[test]
fn editor_background_refuses_clipping_text_without_mutation() {
let mut doc = text_page("BT /F1 12 Tf 7 Tr 72 700 Td (Clip) Tj ET");
let before = page_content_bytes(&doc, 0);
assert!(text::clear_block(&mut doc, 0, 0).is_err());
assert_eq!(page_content_bytes(&doc, 0), before);
}
#[test]
fn explicit_colour_spaces_expose_the_text_colour() {
let doc = text_page("/DeviceRGB cs 0 0.23 0.45 sc BT /F1 12 Tf 72 700 Td (Blue) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].color, [0.0, 0.23, 0.45]);
}
#[test]
fn words_in_separate_text_objects_form_one_editable_paragraph() {
let mut doc = text_page(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \
BT /F1 12 Tf 1 0 0 1 103 700 Tm (world) Tj ET \
BT /F1 12 Tf 1 0 0 1 72 685 Tm (Second) Tj ET \
BT /F1 12 Tf 1 0 0 1 117 685 Tm (line) Tj ET \
BT /F1 12 Tf 1 0 0 1 300 700 Tm (Other column) Tj ET",
);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].text, "Hello world Second line");
assert_eq!(blocks[0].lines.len(), 2);
let other = blocks[1].rect;
text::replace_block(&mut doc, 0, 0, "Replacement").unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Replacement", "Other column"]);
assert_eq!(blocks[1].rect, other);
}
#[test]
fn independent_text_objects_with_different_transforms_stay_separate() {
let doc = text_page(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \
BT /F1 12 Tf 0 1 -1 0 103 700 Tm (Vertical) Tj ET",
);
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2);
}
#[test]
fn text_lines_are_found_and_replaced_in_place() {
let mut doc =
@ -1884,52 +1741,6 @@ fn a_line_drawn_twice_is_replaced_everywhere() {
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
}
#[test]
fn replacing_a_line_keeps_a_distinct_nearby_line() {
// 12 pt lines 6 pt apart: the glyph boxes overlap by more than half the smaller box,
// but the baselines are different lines (#1316).
let src = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Beta) Tj ET";
let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
for paragraph in [false, true] {
let mut doc = text_page(src);
assert_eq!(listed(&doc), ["Alpha", "Beta"]);
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
if paragraph {
text::rewrite_block(&mut doc, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, "ALPHA").unwrap();
}
assert_eq!(listed(&doc), ["ALPHA", "Beta"]);
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("(Beta)"));
for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] {
let back = Document::open(Arc::new(bytes)).unwrap();
assert!(back.repair_log().is_empty());
assert_eq!(listed(&back), ["ALPHA", "Beta"]);
}
}
// The same word on the next line is still a different line.
let mut same = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Alpha) Tj ET");
text::replace_line(&mut same, 0, 0, "ALPHA").unwrap();
assert_eq!(listed(&same), ["ALPHA", "Alpha"]);
// Ordinary leading, and a paragraph far enough to group apart, keep the neighbour.
let mut spaced = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -14.4 Td (Beta) Tj ET");
text::replace_line(&mut spaced, 0, 0, "ALPHA").unwrap();
assert_eq!(listed(&spaced), ["ALPHA", "Beta"]);
let mut apart = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -40 Td (Beta) Tj ET");
text::rewrite_block(&mut apart, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
assert_eq!(listed(&apart), ["ALPHA", "Beta"]);
// A second copy at the same position is still one replacement.
for paragraph in [false, true] {
let mut duplicate = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET");
if paragraph {
text::rewrite_block(&mut duplicate, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
} else {
text::replace_line(&mut duplicate, 0, 0, "ALPHA").unwrap();
}
assert_eq!(listed(&duplicate), ["ALPHA"]);
}
}
#[test]
fn paragraphs_move_and_rewrap_to_a_new_width() {
let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET";
@ -2530,25 +2341,13 @@ fn group_moves_preserve_small_offsets_under_large_transforms() {
let before = editable_objects(&doc, 0).unwrap();
let targets = before.iter().filter(|o| o.text.as_deref() != Some("Neighbour")).map(|o| o.target).collect::<Vec<_>>();
assert_eq!(targets.len(), 2);
let image_before = images::page_images(&doc, 0).unwrap()[0].rect;
let offset = [0.00123456789, -0.00987654321];
move_objects(&mut doc, 0, &targets, offset).unwrap();
let reopened = reopen(&doc);
let image_after = images::page_images(&reopened, 0).unwrap()[0].clone();
assert!(
close(image_after.rect, [image_before[0] + offset[0], image_before[1] + offset[1], image_before[2] + offset[0], image_before[3] + offset[1]]),
"full image placement preserves sub-point movement"
);
let after = editable_objects(&reopened, 0).unwrap();
let after = editable_objects(&reopen(&doc), 0).unwrap();
for old in before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
let [dx, dy] = if targets.contains(&old.target) { offset } else { [0.0, 0.0] };
if old.target.kind == crate::ObjectKind::Image {
// The full placement moves; the visible right/top edges stay at the page crop.
assert_eq!(Some(new.rect), image_after.visible_rect);
} else {
assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}");
}
assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}");
}
}
@ -2858,588 +2657,3 @@ fn group_move_budget_refusals_keep_the_original_page_selectable() {
assert_eq!(editable_objects(&doc, 0).unwrap(), objects, "refusal retains a usable inventory");
}
}
#[test]
fn arabic_actual_text_keeps_typing_order_when_opened_for_editing() {
for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "PDFمرحبا", "مرحبا", "שלום עולם", "Latin text"]
{
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
for drawing in ["(abcd) Tj (efgh) Tj", "(X) Tj"] {
let doc = text_page(&format!("/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td {drawing} ET EMC"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, logical, "ActualText is already in typing order");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical);
}
}
}
}
#[test]
fn latin_ligature_actual_text_stays_on_its_line() {
// Word and Chrome wrap ligatures in ActualText spans mid-word ("fi" drawn, "fi" meant).
let hex: String = "fi".encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = text_page(&format!("BT /F1 12 Tf 72 700 Td (de) Tj /Span <</ActualText <FEFF{hex}>>> BDC (X) Tj EMC (ned well) Tj ET"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["defined well"]);
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, "defined well");
}
}
#[test]
fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() {
let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = hebrew_page(&format!("BT /F1 12 Tf 72 700 Td (EDCBA) Tj /Span <</ActualText <FEFF{hex}>>> BDC (FGHD) Tj EMC (EDCBA) Tj ET"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["בדיוק", "שלום", "בדיוק"]);
}
}
/// Synthetic codes A س, B م and X a lam-alef glyph, drawn from left to right.
fn arabic_ligature_page(content: &str, composite: bool) -> Document {
let prefix = if composite { "00" } else { "" };
let cmap = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <{prefix}00> <{prefix}FF> endcodespacerange 4 beginbfchar \
<{prefix}20> <0020> <{prefix}41> <0633> <{prefix}42> <0645> <{prefix}58> <FEFB> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"
);
let widths: Vec<_> = (32..=88).map(|c| if c == 32 { "250" } else { "500" }).collect();
let font = if composite {
"<< /Type /Font /Subtype /Type0 /BaseFont /FakeArabic /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>".into()
} else {
format!("<< /Type /Font /Subtype /Type1 /BaseFont /FakeArabic /FirstChar 32 /LastChar 88 /Widths [{}] /ToUnicode 6 0 R >>", widths.join(" "))
};
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
&stream("", content),
&font,
&stream("", &cmap),
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /FakeArabic /CIDSystemInfo << /Registry (Synthetic) /Ordering (Identity) /Supplement 0 >> /DW 500 /W [32 [250]] >>",
])
}
#[test]
fn arabic_ligature_actual_text_stays_on_its_line() {
for (ligature, composite) in [("لا", false), ("لأ", false), ("لإ", false), ("لآ", false), ("لَا", false), ("لا", true)] {
let hex: String = ligature.encode_utf16().map(|u| format!("{u:04X}")).collect();
let (left, glyph, right) = if composite { ("<0042>", "<0058>", "<0041>") } else { ("(B)", "(X)", "(A)") };
let doc = arabic_ligature_page(
&format!("BT /F1 12 Tf 72 700 Td {left} Tj /Span <</ActualText <FEFF{hex}>>> BDC [{glyph}] TJ EMC {right} Tj ET"),
composite,
);
let expected = format!("س{ligature}م");
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), [expected.as_str()]);
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected);
}
}
}
#[test]
fn multi_glyph_rtl_actual_text_keeps_its_own_reading_order() {
// Two encoded glyphs are a logical span, even if their replacement looks like a ligature.
for drawing in ["(XX) Tj", "(X) Tj (X) Tj", "[(X) (X)] TJ"] {
let doc =
arabic_ligature_page(&format!("BT /F1 12 Tf 72 700 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC {drawing} EMC (A) Tj ET"), false);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["م", "لا", "س"]);
}
}
#[test]
fn replacing_word_with_actual_text_ligature_removes_all_its_source_operators() {
let mut doc = arabic_ligature_page(
"BT /F1 12 Tf 72 700 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC /Span BMC (X) Tj EMC EMC (A) Tj ET \
BT /F1 12 Tf 72 600 Td (AB) Tj ET",
false,
);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, "سلام");
text::replace_block(&mut doc, 0, 0, "Replacement").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Replacement", "مس"]);
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("ActualText") && !content.contains("(X)") && !content.contains("(B)") && !content.contains("(A)"), "{content}");
}
#[test]
fn replacing_arabic_actual_text_removes_the_whole_original_drawing() {
let logical = "مرحبا بالعالم";
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
let mut doc = text_page(&format!(
"/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC \
BT /F1 12 Tf 72 600 Td (Keep this line) Tj ET"
));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, logical);
text::replace_block(&mut doc, 0, 0, "Replacement").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Replacement", "Keep this line"]);
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("ActualText") && !content.contains("(abcd)") && !content.contains("(efgh)"));
}
fn mixed_region_page() -> Document {
let mut doc = text_page(
"BT /F2 1 Tf 18 0 0 18 123 700 Tm (TITLE) Tj ET \
BT /F2 1 Tf 12 0 0 12 117.6 676 Tm (two lines) Tj ET \
BT /F2 1 Tf 12 0 0 12 135.6 652 Tm (last) Tj ET \
BT 0 0.2 0.5 rg /F2 10 Tf 72 600 Td (About) Tj ET \
BT 0 g /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf 0 0.2 0.5 rg (bold) Tj /F1 10 Tf 0 g ( text.) Tj ET \
BT 0.2 0.4 0.7 rg /F3 8 Tf 72 566 Td (Italic footer) Tj ET \
BT 0 g /F1 10 Tf 400 600 Td (Other column) Tj ET",
);
for (obj, base) in [(5, "Courier"), (6, "Courier-Bold")] {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(base));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
doc.set(pdfcraft_cos::ObjRef::new(obj, 0), Object::Dict(f));
}
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name("Courier-Oblique"));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let font = doc.add(Object::Dict(f));
let page = pdfcraft_model::pages(&doc)[0].clone();
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone();
fonts.set(b"F3".to_vec(), Object::Ref(font));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc
}
#[test]
fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() {
let doc = mixed_region_page();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\nlast");
assert_eq!(blocks[0].rows.len(), 3);
assert_eq!(blocks[1].editor_text, "About\nPlain bold text.\nItalic footer");
assert_eq!(blocks[1].rows.len(), 3);
assert_eq!(blocks[1].lines.len(), 5);
assert_eq!(blocks[2].text, "Other column");
}
#[test]
fn explicit_editor_rows_survive_single_run_edit_and_reopen() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET");
let text = "First row\nSecond row\nLast row";
let layout = text::text_edit_layout(&doc, 0, 0, text, &text::BlockStyle::default()).unwrap();
text::replace_block(&mut doc, 0, 0, text).unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(blocks[0].editor_text, text);
assert_eq!(blocks[0].rows.len(), 3);
assert_eq!(Some(blocks[0].rect), layout.draft_rect);
}
#[test]
fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen() {
let original = mixed_region_page();
let before = text::text_lines(&original, 0).unwrap();
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
let mut doc = original.clone();
text::replace_block(&mut doc, 0, 1, &block.editor_text.replace("Plain", "Other")).unwrap();
let reopened = reopen(&doc);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[1].editor_text, "About\nOther bold text.\nItalic footer");
let lines = text::text_lines(&reopened, 0).unwrap();
for word in ["About", "bold", "Italic footer"] {
let source = before.iter().find(|l| l.text == word).unwrap();
let after = lines.iter().find(|l| l.text == word).unwrap();
assert_eq!(
(after.base_font.as_str(), after.size, after.color, after.bold, after.italic),
(source.base_font.as_str(), source.size, source.color, source.bold, source.italic)
);
assert!((after.origin_baseline() - source.origin_baseline()).abs() < 0.001);
}
for i in [0, 2] {
assert_eq!(blocks[i].editor_text, text::text_blocks(&original, 0).unwrap()[i].editor_text);
assert_eq!(blocks[i].rect, text::text_blocks(&original, 0).unwrap()[i].rect);
}
let mut removed = original.clone();
text::replace_block(&mut removed, 0, 1, "").unwrap();
let after = text::text_blocks(&reopen(&removed), 0).unwrap();
assert_eq!(after.len(), 2);
assert_eq!(after[0].editor_text, "TITLE\ntwo lines\nlast");
assert_eq!(after[1].text, "Other column");
let mut preview = original.clone();
text::clear_block(&mut preview, 0, 1).unwrap();
assert_eq!(text::text_blocks(&reopen(&preview), 0).unwrap().len(), 2);
assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3);
}
#[test]
fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() {
let mut doc = mixed_region_page();
let block = text::text_blocks(&doc, 0).unwrap()[1].clone();
let a = block.editor_text.find("Plain").unwrap();
let b = block.editor_text.find("bold").unwrap();
let style = BlockStyle {
ranges: vec![
RangeStyle {
range: a..a + 5,
family: None,
resource: None,
size: Some(15.0),
color: Some([0.8, 0.1, 0.2]),
underline: Some(true),
char_spacing: None,
scale: None,
},
RangeStyle {
range: b..b + 4,
family: Some((Family::Courier, false, true)),
resource: None,
size: None,
color: None,
underline: None,
char_spacing: Some(0.2),
scale: Some(90.0),
},
],
width: Some(300.0),
..Default::default()
};
text::rewrite_block(&mut doc, 0, 1, Some(&block.editor_text), &style).unwrap();
doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
let plain = lines.iter().find(|l| l.text == "Plain").unwrap();
assert_eq!((plain.size, plain.color), (15.0, [0.8, 0.1, 0.2]));
let bold = lines.iter().find(|l| l.text == "bold").unwrap();
assert_eq!(bold.base_font, "Courier-Oblique");
let original = mixed_region_page();
for word in ["About", "Italic footer"] {
let prior = text::text_lines(&original, 0).unwrap().into_iter().find(|l| l.text == word).unwrap();
let after = lines.iter().find(|l| l.text == word).unwrap();
assert_eq!((after.size, after.color, &after.font, after.baseline()), (prior.size, prior.color, &prior.font, prior.baseline()));
}
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let invalid = BlockStyle {
ranges: vec![RangeStyle {
range: 1..2,
family: None,
resource: None,
size: Some(12.0),
color: None,
underline: None,
char_spacing: None,
scale: None,
}],
..Default::default()
};
assert!(text::rewrite_block(&mut doc, 0, 1, Some("é bad"), &invalid).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
#[test]
fn centered_mixed_size_title_keeps_three_rows_when_changed() {
let mut doc = mixed_region_page();
let title = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::replace_block(&mut doc, 0, 0, &title.editor_text.replace("last", "tail")).unwrap();
let reopened = reopen(&doc);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\ntail");
assert_eq!(blocks[0].rows.len(), 3);
let lines = text::text_lines(&reopened, 0).unwrap();
for row in &blocks[0].rows {
let line = &lines[row[0]];
assert!(((line.rect[0] + line.rect[2]) / 2.0 - 150.0).abs() < 0.01);
}
}
#[test]
fn image_selection_bounds_respect_clips_and_restore_graphics_state() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let content = b"q 1 0 0 1 10 20 cm 20 30 100 80 re W* n q 1200 0 0 1400 -500 -600 cm /Im0 Do Q Q q 200 0 0 100 100 100 cm /Im0 Do Q q 0 0 10 10 re W n 100 0 0 100 200 200 cm /Im0 Do Q";
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
let before = images::page_images(&doc, 0).unwrap();
assert_eq!(before.len(), 3, "fully clipped placements retain source indexes");
assert_eq!(before[0].visible_rect, Some([30.0, 50.0, 130.0, 130.0]));
assert_eq!(before[1].visible_rect, Some(before[1].rect), "Q restores the prior clip");
assert_eq!(before[2].visible_rect, None);
let objects = crate::editable_objects(&doc, 0).unwrap();
assert_eq!(objects.len(), 2, "hidden images cannot intercept selection");
assert_eq!(objects[0].rect, before[0].visible_rect.unwrap());
let offset = [12.0, 14.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(pdfcraft_content::Matrix::translate(offset[0], offset[1]).0)).unwrap();
let reopened = reopen(&doc);
let after = images::page_images(&reopened, 0).unwrap();
assert_eq!(after[0].visible_rect, Some([42.0, 64.0, 142.0, 144.0]), "an exclusive crop moves with the image even across nested graphics states");
for (a, b) in after[0].rect.iter().zip(before[0].rect.iter()).enumerate() {
assert!((b.0 - b.1 - offset[a % 2]).abs() < 0.001);
}
assert_eq!(after[1].rect, before[1].rect, "neighbor placement unchanged");
assert_eq!(after[1].visible_rect, before[1].visible_rect);
assert_eq!(after[1].matrix, before[1].matrix);
assert_eq!(after[1].object, before[1].object);
}
#[test]
fn image_clip_path_and_graphics_state_continue_across_content_streams() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let pieces: [&[u8]; 3] = [b"q 20 30 40 50 re W", b"n 1000 0 0 1000 -300 -400 cm /Im0 Do", b"Q q 50 0 0 50 100 200 cm /Im0 Do Q"];
let refs: Vec<_> = pieces.into_iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let images = images::page_images(&doc, 0).unwrap();
assert_eq!(images[0].visible_rect, Some([20.0, 30.0, 60.0, 80.0]));
assert_eq!(images[1].visible_rect, Some([100.0, 200.0, 150.0, 250.0]));
images::change_image(
&mut doc,
0,
0,
&images::ImageChange::Transform(images::rect_to_rect(images[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0])),
)
.unwrap();
let after = images::page_images(&reopen(&doc), 0).unwrap();
assert!(close(after[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0]), "an exclusive crop scales with its image across streams");
assert_eq!(after[1].matrix, images[1].matrix, "graphics state restored across stream boundaries");
}
#[test]
fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let stream = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET",
)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] {
let before = images::page_images(&doc, 0).unwrap()[0].clone();
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap()[0].clone();
// The moved clip's `cm` is written with the content stream's number precision.
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05);
assert!(near(after.visible_rect.unwrap(), target), "{:?} vs {target:?}", after.visible_rect);
assert!(near(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect)), "full pixels use the same crop transform");
assert_eq!(after.object, before.object, "pixels are never resampled or replaced");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [30.0, 130.0]);
}
let page = pdfcraft_model::pages(&doc)[0].clone();
let stream = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 30 170 130 80 re W n q 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 40 200 Td (Shared caption) Tj ET Q",
)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
let before = images::page_images(&doc, 0).unwrap();
let caption = text::text_lines(&doc, 0).unwrap();
let transform = [1.2, 0.0, 0.0, 1.2, 0.0, 0.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert!(close(after[0].visible_rect.unwrap(), before[0].visible_rect.unwrap()), "a shared clip stays where it is");
assert_eq!(after[0].object, before[0].object);
assert_eq!(
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>()
);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform([0.5, 0.0, 0.0, 0.5, 0.0, 0.0])).unwrap();
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1);
}
#[test]
fn private_image_crop_with_intervening_state_resizes_across_streams() {
let variants: Vec<Vec<&[u8]>> = vec![
vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W* n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"],
vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W*", b"n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do", b"Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"],
];
for pieces in variants {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let caption = text::text_lines(&doc, 0).unwrap();
for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] {
let before = images::page_images(&doc, 0).unwrap()[0].clone();
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap()[0].clone();
assert!(close(after.visible_rect.unwrap(), target), "the private crop follows the image without a literal operator pattern");
assert!(close(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect)));
assert_eq!(after.object, before.object);
assert_eq!(
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>()
);
}
}
}
#[test]
fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let bytes =
b"q 0 0 m 0 200 100 200 100 0 c h W n 300 0 0 300 0 0 cm /Im0 Do Q q 100 100 100 100 re 125 125 50 50 re W* n 400 0 0 400 0 0 cm /Im0 Do Q";
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
let images = images::page_images(&doc, 0).unwrap();
assert!(close(images[0].visible_rect.unwrap(), [0.0, 0.0, 100.0, 150.0]), "cubic extrema, not controls");
assert!(images[0].contains([50.0, 100.0]));
assert!(!images[0].contains([2.0, 140.0]), "empty corners of curve bounds are not artwork");
assert!(images[1].contains([110.0, 110.0]));
assert!(!images[1].contains([150.0, 150.0]), "even-odd hole");
images::change_image(&mut doc, 0, 1, &images::ImageChange::Transform([1.2, 0.0, 0.0, 1.2, 10.0, 15.0])).unwrap();
let after = images::page_images(&reopen(&doc), 0).unwrap();
assert!(!after[1].contains([190.0, 195.0]), "the transformed even-odd hole remains empty");
assert!(after[1].contains([142.0, 147.0]), "the transformed filled corner remains selectable");
assert_eq!(after[0].visible_rect, images[0].visible_rect, "the neighbouring curved clip stays put");
}
#[test]
fn marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite() {
let mut doc =
text_page("/P << /MCID 0 >> BDC BT /F1 12 Tf 40 500 Td (One) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf 40 482 Td (Two) Tj ET EMC");
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "distinct explicit paragraphs win over nearby geometry");
let page = pdfcraft_model::pages(&doc)[0].obj;
let parts: [&[u8]; 2] =
[b"/P << /MCID 2 >> BDC BT /F1 12 Tf 40 500 Td (First row) Tj ET", b"q 1 0 0 1 10 12 cm BT /F1 12 Tf 30 470 Td (Second row) Tj ET Q EMC"];
let refs = parts.iter().map(|p| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), p))))).collect();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let block = text::text_blocks(&doc, 0).unwrap();
assert_eq!(block.len(), 1, "one marked paragraph across streams and coordinate spaces");
assert_eq!(block[0].editor_text, "First row\nSecond row");
let original = text::text_lines(&doc, 0).unwrap();
text::rewrite_block(&mut doc, 0, 0, Some("First row\nChanged row"), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap();
let after = text::text_lines(&reopen(&doc), 0).unwrap();
for (a, b) in after.iter().zip(original) {
assert!(close(a.rect, b.rect) || a.baseline() == b.baseline());
}
assert_eq!(after[1].text, "Changed row");
}
#[test]
fn logical_indic_with_nuktas_not_reordered_on_standard_font() {
// Already logical Hindi text with nuktas (बढ़िया) must not be corrupted by pre-base reordering
// when the font does not require visual order.
let hex_actual = "<FEFF092C0922093C093F092F093E>";
let doc = text_page(&format!("/Span << /ActualText {hex_actual} >> BDC BT /F1 12 Tf 40 500 Td (ab) Tj ET EMC"));
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
assert!(!lines[0].visual_order);
}
/// One page in a font named `base_font` whose ToUnicode maps A ब, B ि, C ढ़, D य, E ा, F क, G त.
/// A legacy visual-order font draws "बढ़िया" as `(ABCDE)` (ब ि ढ़ या) and "किताब" as `(BFGEA)`.
fn devanagari_page(base_font: &str, content: &str) -> Document {
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 8 beginbfchar \
<20> <0020> <41> <092C> <42> <093F> <43> <0922093C> <44> <092F> <45> <093E> <46> <0915> <47> <0924> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
let widths: Vec<&str> = (32..=71)
.map(|c| {
if c == 32 {
"250"
} else if c >= 65 {
"500"
} else {
"0"
}
})
.collect();
let font = format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /{base_font} /FirstChar 32 /LastChar 71 /Widths [{}] /ToUnicode 6 0 R >>",
widths.join(" ")
);
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
&stream("", content),
&font,
&stream("", cmap),
])
}
/// A legacy visual-order font (Kruti Dev) whose ToUnicode gives the glyphs in drawing order: the
/// pre-base vowel sign is read back after its consonant, for editing and for reading.
#[test]
fn legacy_indic_font_flags_visual_order_and_reorders_pre_base_vowels() {
let doc = devanagari_page("KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].visual_order);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]);
}
/// Typed (logical) text is written back in the font's visual order and reads back as typed.
#[test]
fn legacy_indic_font_edits_are_written_in_visual_order() {
let mut doc = devanagari_page("ABCDEF+KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET");
let kitab = "\u{0915}\u{093F}\u{0924}\u{093E}\u{092C}";
assert_eq!(text::replace_line(&mut doc, 0, 0, kitab).unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(BFGEA)"), "{content}");
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, kitab);
}
/// `/ActualText` is already in logical order, even in a visual-order font: "बढ़िया" must not
/// become "बढ़यिा".
#[test]
fn logical_actual_text_in_a_legacy_indic_font_is_not_reordered() {
let doc = devanagari_page("KrutiDev010", "/Span << /ActualText <FEFF092C0922093C093F092F093E> >> BDC BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET EMC");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]);
}
/// A modern subset font with a logical ToUnicode and no GSUB in the PDF (subsets and bare CFF
/// often drop it) is not visual order: its text is read as it is.
#[test]
fn subset_indic_font_without_legacy_name_keeps_logical_text() {
let doc = devanagari_page("ABCDEF+NotoSansDevanagari-Regular", "BT /F1 12 Tf 40 500 Td (ACBDE) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(!lines[0].visual_order);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
}
/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold`
/// …), so it has to be *measured* with that same face. Measuring the upright face while writing
/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722.
#[test]
fn an_items_measured_face_is_the_face_it_is_written_as() {
for family in [Family::Helvetica, Family::Times, Family::Courier] {
for bold in [false, true] {
for italic in [false, true] {
let written = family.base_font(bold, italic);
let measured = family.std14(bold, italic);
assert_eq!(written, measured.base_font(), "{family:?} bold={bold} italic={italic}");
// ... and the public measure reaches that face, rather than an upright stand-in.
assert_eq!(family.width("Anna", 12.0, bold, italic), measured.text_width("Anna", 12.0), "{family:?} bold={bold} italic={italic}");
}
}
}
// The faces really do differ, so the assertions above are not all comparing the same number.
let times = |italic| Family::Times.width("Anna", 12.0, false, italic);
assert_ne!(times(false), times(true), "Times-Italic is not Times-Roman");
// Courier is monospaced in every style: equal widths here are correct, not a collapsed face.
let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic);
assert_eq!(courier(false), courier(true));
}

File diff suppressed because it is too large Load Diff

View File

@ -1,725 +0,0 @@
//! Geometry-based regions and mixed-style rewriting of existing text.
use super::*;
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm));
a.scope == b.scope
&& [0, 2].iter().all(|axis| {
let ax = at.0.get(*axis).copied().unwrap_or(0.0);
let ay = at.0.get(axis + 1).copied().unwrap_or(0.0);
let bx = bt.0.get(*axis).copied().unwrap_or(0.0);
let by = bt.0.get(axis + 1).copied().unwrap_or(0.0);
let al = ax.hypot(ay);
let bl = bx.hypot(by);
al > 0.0 && bl > 0.0 && (ax / al - bx / bl).abs() < 0.001 && (ay / al - by / bl).abs() < 0.001
})
}
fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
[a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
}
fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union)
}
pub(super) fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
group_layout(lines, true)
}
/// Exporters currently represent a block with one style. Keep their style boundaries until
/// they support rich runs, while edit selection may encompass the complete visual region.
pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
group_layout(lines, false)
}
/// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`])
/// unless the text matrix stretches the glyphs, as an OCR layer fitting each word to the scan does.
fn across(line: &TextLine) -> f64 {
let m = Matrix(line.origin.tm).then(&Matrix(line.origin.ctm)).0;
let h = m[0].hypot(m[1]);
if h.is_finite() && h > 1e-9 { h } else { line.origin.k }
}
/// Invisible text (rendering mode 3), as in an OCR layer over a scanned image.
fn invisible(line: &TextLine) -> bool {
line.origin.state.render_mode == 3
}
fn same_style(a: &TextLine, b: &TextLine) -> bool {
same_font(a, b.font.as_bytes(), b.face.as_deref())
&& a.base_font == b.base_font
&& a.bold == b.bold
&& a.italic == b.italic
&& a.color == b.color
&& (a.rotation() - b.rotation()).abs() < 0.01
&& (a.size - b.size).abs() < 0.01
}
fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
let mut rows: Vec<Vec<usize>> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| {
let gap = line.rect[0] - prev.rect[2];
// OCR words (invisible text over a scan) join only across a word space: a wider gap
// is a table cell or a tab stop, which a rewrite would close up.
let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6));
scanned
&& (mixed || same_style(prev, line))
&& same_plane(prev, line)
&& (prev.origin.baseline - line.origin.baseline).abs() < prev.size.min(line.size) * 0.2
&& gap >= -prev.size.min(line.size) * 0.2
&& gap < prev.size.max(line.size) * 1.5
// Separate same-style text objects that merely touch or overlap, with no space
// between them, are separate labels (for example a paragraph dragged against
// another, #594). Style runs and words separated by a space or gap still join.
&& (prev.origin.bt_op == line.origin.bt_op
|| gap > prev.size.min(line.size) * 0.01
|| !same_style(prev, line)
|| prev.text.ends_with(char::is_whitespace)
|| line.text.starts_with(char::is_whitespace))
});
if joins && let Some(row) = rows.last_mut() {
row.push(i);
} else {
rows.push(vec![i]);
}
}
let mut blocks: Vec<TextBlock> = Vec::new();
for row in rows {
let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue };
let Some(rect) = row_rect(&row, lines) else { continue };
let joins = blocks.last().is_some_and(|block| {
let Some(prev_row) = block.rows.last() else { return false };
let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false };
let Some(pr) = row_rect(prev_row, lines) else { return false };
let gap = prev.origin.baseline - first.origin.baseline;
let tol = prev.size.max(first.size).max(1.0);
let aligned = (pr[0] - rect[0]).abs() < tol || ((pr[0] + pr[2] - rect[0] - rect[2]) / 2.0).abs() < tol || (pr[2] - rect[2]).abs() < tol;
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
(!mixed || !(invisible(prev) || invisible(first)))
&& (mixed || same_style(prev, first))
&& same_plane(prev, first)
&& aligned
&& first.size.min(prev.size) >= first.size.max(prev.size) * 0.4
&& gap > first.size.min(prev.size) * 0.8
&& gap < first.size.max(prev.size) * 2.5
});
if !joins {
blocks.push(TextBlock {
text: String::new(),
editor_text: String::new(),
spans: Vec::new(),
rect,
base_font: first.base_font.clone(),
size: first.size,
bold: first.bold,
italic: first.italic,
color: first.color,
rotation: first.rotation(),
lines: Vec::new(),
rows: Vec::new(),
invisible: true,
});
}
let Some(block) = blocks.last_mut() else { continue };
let mut row_text = String::new();
if !block.rows.is_empty() {
block.editor_text.push('\n');
}
for &index in &row {
let Some(line) = lines.get(index) else { continue };
// Some producers position separate words without encoding a space.
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
&& !row_text.ends_with(char::is_whitespace)
&& !line.text.starts_with(char::is_whitespace)
&& line.rect[0] - prev.rect[2] > line.size * 0.12
{
row_text.push(' ');
block.editor_text.push(' ');
}
let start = block.editor_text.len();
block.editor_text.push_str(&line.text);
block.spans.push((start..block.editor_text.len(), index));
row_text.push_str(&line.text);
block.invisible &= line.origin.state.render_mode == 3;
block.lines.push(index);
}
if !block.text.is_empty() {
if block.text.ends_with('-') {
block.text.pop();
} else {
block.text.push(' ');
}
}
block.text.push_str(row_text.trim_matches(paragraph_separator));
block.rect = union(block.rect, rect);
block.rows.push(row);
}
blocks
}
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
fn shape_source(
doc: &Document,
fonts: &Dict,
name: &str,
text: &str,
) -> Result<Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> {
if !pdfcraft_fonts::source_shaping::required(text) {
return Ok(None);
}
let source =
fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?;
let family = Metrics::from_dict(doc, &source).base_font;
let mut candidates = vec![source];
candidates.extend(
fonts
.iter()
.filter_map(|(_, f)| doc.dict(f))
.filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)),
);
for font in candidates {
if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)
&& pdfcraft_fonts::embedding::covers(&bytes, text)
&& let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text)
{
return Ok(Some((bytes, shaped, family)));
}
}
Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into()))
}
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
pub(super) fn rewrite_styled_block(
doc: &mut Document,
page: usize,
block: &TextBlock,
lines: &[TextLine],
text: Option<&str>,
style: &BlockStyle,
) -> Result<LineEdit, EditError> {
let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
let draft = match text {
Some(t) if t != block.text => t,
_ => &block.editor_text,
};
if draft.len() > 4_000_000 {
return Err(EditError::Invalid("the replacement text is too large".into()));
}
if draft == block.editor_text && *style == BlockStyle::default() {
return Ok(LineEdit { substituted: None });
}
let mut ranges: Vec<_> = style.ranges.iter().collect();
ranges.sort_by_key(|r| r.range.start);
if ranges.len() > 1024
|| ranges.iter().any(|r| {
r.range.start >= r.range.end
|| draft.get(r.range.clone()).is_none()
|| [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite())
|| r.size.is_some_and(|n| n <= 0.0 || n > 14400.0)
|| r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n)))
})
|| ranges.windows(2).any(|r| r[0].range.end > r[1].range.start)
{
return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into()));
}
let mut styled_lines = lines.to_vec();
let mut styled_spans = Vec::new();
let mut underlines = HashMap::new();
let mut overrides = HashMap::new();
for (span, source) in block.edited_spans(draft) {
let mut edges = vec![span.start, span.end];
for r in &ranges {
for edge in [r.range.start, r.range.end] {
if span.contains(&edge) {
edges.push(edge);
}
}
}
edges.sort_unstable();
edges.dedup();
for bounds in edges.windows(2) {
let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue };
let mut index = source;
let selected = ranges.iter().find(|r| r.range.contains(&start)).copied();
if selected.is_some() || style.family.is_some() || style.bold.is_some() {
let default_range = RangeStyle {
range: start..end,
family: None,
resource: None,
size: None,
color: None,
underline: None,
char_spacing: None,
scale: None,
};
let r = selected.unwrap_or(&default_range);
let mut line = lines.get(source).unwrap_or(first).clone();
if let Some((family, bold, italic)) =
r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic)))
{
let base = family.base_font(bold, italic);
line.font = format!("PCRange{}", base.replace('-', ""));
line.base_font = base.to_owned();
line.bold = bold;
line.italic = italic;
}
if let Some(resource) = &r.resource {
line.font = resource.clone();
}
if let Some(size) = r.size {
line.size = size;
}
if let Some(color) = r.color {
line.color = color;
line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())];
}
if let Some(spacing) = r.char_spacing {
line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6);
}
if let Some(scale) = r.scale {
line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0);
}
index = styled_lines.len();
styled_lines.push(line);
if let Some(r) = selected {
overrides.insert(index, r);
}
if let Some(underline) = r.underline {
underlines.insert(index, underline);
}
}
styled_spans.push((start..end, index));
}
}
let lines = styled_lines.as_slice();
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(&line.base_font));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f));
}
let mut metrics = std::collections::BTreeMap::new();
for (name, object) in fonts.iter().take(256) {
if let Some(font) = doc.resolve(object).as_dict() {
metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font));
}
}
for index in &block.lines {
if let Some(line) = lines.get(*index)
&& !metrics.contains_key(&line.font)
&& let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())
{
metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font));
}
}
if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite())
|| style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite()))
{
return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into()));
}
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) {
return Err(EditError::Invalid("the paragraph's position and width must be finite".into()));
}
// A rewritten OCR line grows to the right rather than wrapping onto the scanned line below.
let natural = if block.invisible {
(p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0])
} else {
block.rect[2] - block.rect[0]
};
let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0);
// Each tuple is a character, its source style and an encodable resource name.
let mut chars = Vec::new();
let mut substituted = None;
let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new();
let mut full_faces: HashMap<String, String> = HashMap::new();
for (range, index) in styled_spans {
let source = lines.get(index).unwrap_or(first);
for ch in draft.get(range).unwrap_or_default().chars() {
if ch == '\n' {
chars.push((ch, index, source.font.clone()));
continue;
}
let s = ch.to_string();
let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) {
if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) {
return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch))));
}
resource.clone()
} else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) {
source.font.clone()
} else if let Some((name, _)) = metrics.iter().find(|(_, m)| {
pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s)
}) {
name.clone()
} else if let Some(face) =
pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s))
{
let key = face.name();
if let Some(name) = full_faces.get(&key) {
name.clone()
} else {
let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?;
let font = fonts
.get(name.as_bytes())
.and_then(|f| doc.dict(f))
.ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?;
metrics.insert(name.clone(), Metrics::from_dict(doc, &font));
full_faces.insert(key, name.clone());
name
}
} else {
let family = source_family(&source.base_font);
if needs_type3(&s) {
let key = (source.base_font.rsplit('+').next().unwrap_or(&source.base_font).to_owned(), ch);
if let Some(name) = fallback_names.get(&key) {
chars.push((ch, index, name.clone()));
continue;
}
if fallback_names.len() >= MAX_TYPE3_GLYPHS {
return Err(EditError::Invalid("the replacement has too many fallback glyphs".into()));
}
let fallback = type3_font(doc, &mut fonts, &s, family, source.bold, MAX_TYPE3_GLYPHS)?;
let name = fallback.names.first().cloned().ok_or_else(no_japanese_font)?;
let dict = fonts
.get(name.as_bytes())
.and_then(|f| doc.resolve(f).as_dict().cloned())
.ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?;
metrics.insert(name.clone(), Metrics::from_dict(doc, &dict));
substituted = Some(fallback.family.to_string());
fallback_names.insert(key, name.clone());
name
} else {
let base = family.base_font(source.bold, source.italic);
let name = format!("PCEd{}", base.replace('-', ""));
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(base));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f));
fonts.set(name.clone().into_bytes(), Object::Dict(f));
substituted = Some(base.to_string());
name
}
};
chars.push((ch, index, name));
}
}
let size_of = |source: &TextLine, index: usize| {
overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
};
// A source run may carry TJ kerning/justification that is absent from its text state.
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
let mut calibration = HashMap::new();
for (index, source) in lines.iter().enumerate() {
if let Some(m) = metrics.get(&source.font) {
let nominal = source_width(
m,
&source.text,
source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s),
source_space(doc, &fonts, m),
source.origin.state.char_spacing,
source.origin.state.word_spacing,
) * source.origin.state.scale
* across(source);
let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some());
let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 };
calibration.insert(index, factor);
}
}
let advance = |item: &(char, usize, String)| -> f64 {
let source = lines.get(item.1).unwrap_or(first);
let Some(m) = metrics.get(&item.2) else { return 0.0 };
source_width(
m,
&item.0.to_string(),
size_of(source, item.1),
source_space(doc, &fonts, m),
overrides
.get(&item.1)
.and_then(|r| r.char_spacing)
.or(style.char_spacing)
.map_or(source.origin.state.char_spacing, |c| c / source.origin.k.max(1e-6)),
source.origin.state.word_spacing,
) * overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0))
* across(source)
* calibration.get(&item.1).copied().unwrap_or(1.0)
};
let measure = |items: &[(char, usize, String)]| -> Result<f64, EditError> {
let mut width = 0.0;
let mut at = 0;
while let Some(item) = items.get(at) {
let mut end = at + 1;
while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) {
end += 1;
}
let segment = items.get(at..end).unwrap_or_default();
let string: String = segment.iter().map(|v| v.0).collect();
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? {
let source = lines.get(item.1).unwrap_or(first);
let scale =
overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0));
let spacing = overrides
.get(&item.1)
.and_then(|r| r.char_spacing)
.or(style.char_spacing)
.map_or(source.origin.state.char_spacing, |s| s / source.origin.k.max(1e-6));
width += (shaped.advance * size_of(source, item.1)
+ spacing * string.chars().count() as f64
+ source.origin.state.word_spacing * string.matches(' ').count() as f64)
* scale
* source.origin.k;
} else {
width += segment.iter().map(&advance).sum::<f64>();
}
at = end;
}
Ok(width)
};
let mut rows: Vec<Vec<(char, usize, String)>> = Vec::new();
let mut row = Vec::new();
let mut row_width = 0.0;
// Wrap at word boundaries, retaining explicit source row breaks and whitespace.
let mut at = 0;
while at < chars.len() {
let Some(item) = chars.get(at) else { break };
if item.0 == '\n' {
rows.push(std::mem::take(&mut row));
row_width = 0.0;
at += 1;
continue;
}
let mut end = at + 1;
if !item.0.is_whitespace() {
while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) {
end += 1;
}
}
let word = chars.get(at..end).unwrap_or_default();
let w = measure(word)?;
if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 {
while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) {
row.pop();
}
rows.push(std::mem::take(&mut row));
row_width = 0.0;
}
row.extend_from_slice(word);
row_width += w;
at = end;
}
if !row.is_empty() {
rows.push(row);
}
for row in &rows {
let text: String = row.iter().map(|v| v.0).collect();
if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) {
return Err(EditError::Invalid(
"contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported"
.into(),
));
}
}
let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::<Result<_, _>>()?;
let mut shaped_runs = HashMap::new();
let mut segment_advances = HashMap::new();
for (row_index, row) in rows.iter().enumerate() {
let mut at = 0;
while let Some(item) = row.get(at) {
let mut end = at + 1;
while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) {
end += 1;
}
segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::<f64>());
let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect();
if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? {
shaped_runs.insert((row_index, at), (bytes, shaped, family));
}
at = end;
}
}
let mut glyph_resources = HashMap::new();
for (key, (bytes, shaped, family)) in &shaped_runs {
let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect();
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
glyph_resources.insert(*key, resource);
}
let n = pdfcraft_content::num;
let mut block_ops = vec![Op::new("q", vec![])];
block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?);
let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0])
.invert()
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
let (tx, ty) = back.apply(dx, dy);
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
let centre = (block.rect[0] + block.rect[2]) / 2.0;
let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect();
let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min);
let centered = block.rows.len() > 1
&& left_span > first.size * 0.5
&& block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < first.size));
let source_leading = block
.rows
.windows(2)
.filter_map(|rows| {
let a = rows[0].first().and_then(|i| lines.get(*i))?;
let b = rows[1].first().and_then(|i| lines.get(*i))?;
Some((a.origin.baseline - b.origin.baseline).abs())
})
.rfind(|v| *v > 0.0)
.unwrap_or(first.size * 1.5);
let mut baseline = first.origin.baseline;
for (row_index, row) in rows.iter().enumerate() {
let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i));
if style.line_spacing.is_none()
&& style.size.is_none()
&& let Some(anchor) = anchor
{
baseline = anchor.origin.baseline;
}
let anchor = anchor.unwrap_or(first);
let w = measured.get(row_index).copied().unwrap_or(0.0);
let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] };
let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width };
let mut x = left
+ match align {
crate::added::Align::Center => (available - w) / 2.0,
crate::added::Align::Right => available - w,
_ => 0.0,
};
let mut pos = 0;
while pos < row.len() {
let Some(item) = row.get(pos) else { break };
let source = lines.get(item.1).unwrap_or(first);
let mut end = pos + 1;
while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) {
end += 1;
}
let segment = row.get(pos..end).unwrap_or_default();
let string: String = segment.iter().map(|c| c.0).collect();
let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?;
let mut state = source.origin.state.clone();
state.font = Some((item.2.clone(), size_of(source, item.1)));
if let Some(c) = overrides.get(&item.1).and_then(|r| r.char_spacing).or(style.char_spacing) {
state.char_spacing = c / source.origin.k.max(1e-6);
}
if let Some(s) = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale) {
state.scale = (s / 100.0).clamp(0.1, 10.0);
}
if !shaped_runs.contains_key(&(row_index, pos)) {
state.scale *= calibration.get(&item.1).copied().unwrap_or(1.0);
}
if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) {
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])];
}
let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?;
let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0;
let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline);
tm[4] += ax;
tm[5] += ay;
let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0);
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
let mut properties = Dict::new();
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string)));
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
state.font = Some((resource.clone(), size_of(source, item.1)));
// Absolute glyph matrices preserve marks and contextual positions; font-program
// codes are separate from Unicode and ToUnicode/ActualText keep logical text.
let size = size_of(source, item.1);
let mut extra = 0.0;
for (i, glyph) in shaped.glyphs.iter().enumerate() {
let mut matrix = tm;
let x = (glyph.position[0] * size + extra) * state.scale;
let y = glyph.position[1] * size;
matrix[4] += matrix[0] * x + matrix[2] * y;
matrix[5] += matrix[1] * x + matrix[3] * y;
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
block_ops.push(Op::new("BT", vec![]));
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", matrix.map(n).to_vec()));
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))]));
block_ops.push(Op::new("ET", vec![]));
extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
}
block_ops.push(Op::new("EMC", vec![]));
segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k;
} else {
block_ops.push(Op::new("BT", vec![]));
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
block_ops.extend(source_show(
m,
&string,
size_of(source, item.1),
source_space(doc, &fonts, m),
state.char_spacing,
state.word_spacing,
)?);
block_ops.push(Op::new("ET", vec![]));
}
if underlines.get(&item.1).copied().or(style.underline) == Some(true) {
let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
let underline_size = size_of(source, item.1) * source.origin.k;
let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)]));
block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))]));
block_ops.push(Op::new("m", vec![n(x0), n(y0)]));
block_ops.push(Op::new("l", vec![n(x1), n(y1)]));
block_ops.push(Op::new("S", vec![]));
}
x += segment_width;
pos = end;
}
baseline -= if style.line_spacing.is_none() && style.size.is_none() {
source_leading
} else {
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
};
}
block_ops.push(Op::new("Q", vec![]));
let streams = content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
let mut drop = coincident_ops(lines, &members);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
let o = &first.origin;
let start = first.ops.first().copied().unwrap_or(o.bt_op);
let split = ops
.get(o.bt_op..start)
.unwrap_or_default()
.iter()
.enumerate()
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i)));
let after = if split { &o.state } else { &o.bt_state };
block_ops.extend(after.ops());
if split {
block_ops.insert(0, Op::new("ET", vec![]));
block_ops.push(Op::new("BT", vec![]));
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
}
let at = if split { start } else { o.bt_op };
let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i)));
let contents = rewritten_contents(doc, &streams, data);
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
d.set(b"Resources".to_vec(), Object::Dict(res));
})?;
Ok(LineEdit { substituted })
}

View File

@ -78,7 +78,3 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
- Performance budgets (`misc.performance-budgets`) are not set or enforced.
- Some shipped features are still reachable only from the UI, not through an `automation` tool;
`cargo xtask parity` lists them; closing that gap is tracked in the execution plan (D4).
`doc.text_edit_preview(page, block)` prepares a read-only page background with the selected
paragraph’s glyphs removed, preserving text positioning, images and colours. It shares the
working document’s render configuration and never enters the undo history.

View File

@ -1,86 +0,0 @@
//! Reviewable structure proposals for pages that have not yet been tagged.
//!
//! A proposal is deliberately not a PDF tag. It records the visible text blocks and the
//! evidence for a tentative role, so a later edit can attach marked content and a parent tree
//! without presenting a font-size guess as a finished accessible document.
use crate::Document;
/// A bounded text block proposed for a structure element.
#[derive(Clone, Debug, PartialEq)]
pub struct Candidate {
pub kind: &'static str,
pub reason: &'static str,
pub text: String,
pub text_truncated: bool,
pub rect: [f64; 4],
pub size: f64,
pub bold: bool,
}
/// The visible text portion of one page's proposed structure.
#[derive(Clone, Debug, PartialEq)]
pub struct Preview {
pub candidates: Vec<Candidate>,
/// Raster or grouped artwork requires separate figure/artifact review.
pub unclassified_images: usize,
/// Text blocks omitted because their geometry or font size was invalid.
pub unclassified_text_blocks: usize,
/// Existing structure must be reconciled before any proposal is applied.
pub existing_structure: bool,
}
const MAX_BLOCKS: usize = 2_048;
const MAX_TEXT_CHARS: usize = 512;
impl Document {
/// Propose text tags for one 0-based page. This reads the current working PDF and does not
/// alter it. Every result needs review before tags are written; image and table semantics
/// are outside this text-only proposal.
pub fn autotag_preview(&self, page: usize) -> Result<Preview, String> {
if self.info.pages.get(page).is_none() {
return Err(format!("page {} is outside the document", page.saturating_add(1)));
}
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let existing_structure = editor.cos.root().is_some_and(|r| editor.cos.get(r).as_dict().is_some_and(|d| d.contains(b"StructTreeRoot")));
let blocks = pdfcraft_edit::text_blocks(&editor.cos, page).map_err(|e| e.to_string())?;
if blocks.len() > MAX_BLOCKS {
return Err(format!("page {} has too many text blocks to preview (maximum {MAX_BLOCKS})", page.saturating_add(1)));
}
let mut sizes: Vec<f64> = blocks.iter().map(|b| b.size).filter(|s| s.is_finite() && *s > 0.0).collect();
sizes.sort_by(f64::total_cmp);
let body_size = sizes.get(sizes.len().saturating_sub(1) / 2).copied().unwrap_or(12.0);
let mut unclassified_text_blocks = 0;
let candidates = blocks
.into_iter()
.filter(|b| {
let valid = !b.text.trim().is_empty() && b.rect.iter().all(|v| v.is_finite()) && b.size.is_finite() && b.size > 0.0;
if !valid {
unclassified_text_blocks += 1;
}
valid
})
.map(|b| {
let mut chars = b.text.chars();
let text: String = chars.by_ref().take(MAX_TEXT_CHARS).collect();
let text_truncated = chars.next().is_some();
let short_line = b.rows.len() == 1 && b.text.chars().count() <= 120;
let heading = short_line && b.size.is_finite() && b.size > body_size * 1.35;
Candidate {
kind: if heading { "H1" } else { "P" },
reason: if heading {
"short, single-line text larger than the page body"
} else {
"visible text block; semantic role needs review"
},
text,
text_truncated,
rect: b.rect,
size: b.size,
bold: b.bold,
}
})
.collect();
Ok(Preview { candidates, unclassified_images: self.page_images(page).len(), unclassified_text_blocks, existing_structure })
}
}

View File

@ -1,35 +0,0 @@
//! What this build calls itself. A local or patched build sets `PDFCRAFT_BUILD_LABEL` at compile
//! time (`PDFCRAFT_BUILD_LABEL=titlebar cargo build`, or through `packaging/linux/package.sh`), so
//! it can be told from the release of the same version. The version itself stays the release's:
//! the update check compares it with published releases.
/// The release version from the workspace `Cargo.toml`.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The compile-time build label, if any.
pub const LABEL: Option<&str> = option_env!("PDFCRAFT_BUILD_LABEL");
/// The version as shown to people: `0.7.1`, or `0.7.1 (titlebar)` for a labelled build.
pub fn version() -> String {
describe(VERSION, LABEL)
}
fn describe(version: &str, label: Option<&str>) -> String {
match label.map(str::trim).filter(|l| !l.is_empty()) {
Some(label) => format!("{version} ({label})"),
None => version.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::describe;
#[test]
fn a_label_follows_the_version_and_a_blank_one_is_ignored() {
assert_eq!(describe("0.7.1", None), "0.7.1");
assert_eq!(describe("0.7.1", Some("")), "0.7.1");
assert_eq!(describe("0.7.1", Some(" ")), "0.7.1");
assert_eq!(describe("0.7.1", Some("titlebar")), "0.7.1 (titlebar)");
}
}

View File

@ -232,8 +232,7 @@ fn support(a: Point, b: Point, edges: &[Option<Edge>], w: usize, h: usize) -> f6
edges.get(y as usize * w + x as usize).copied().flatten().is_some_and(|e| {
// Either way across: dark-to-light or light-to-dark.
let d = (e.dir - normal).rem_euclid(std::f32::consts::PI);
// A NaN direction is no hit, as the two comparisons it replaces said.
!d.is_nan() && !(0.35..=std::f32::consts::PI - 0.35).contains(&d)
d < 0.35 || d > std::f32::consts::PI - 0.35
})
});
hits += hit as usize;

View File

@ -71,7 +71,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("Microsoft Word (.docx)", "file-text", "export.docx", Ready),
item("Spreadsheet (.xlsx)", "grid-3x3", "export.xlsx", Planned("M10")),
item("Presentation (.pptx)", "presentation", "export.pptx", Ready),
item("Presentation (.pptx)", "presentation", "export.pptx", Planned("M10")),
item("Image (PNG)", "image", "export.image", Ready),
item("Export all images", "image", "export.all_images", Ready),
item("HTML web page", "file-symlink", "export.html", Ready),
@ -150,7 +150,6 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("Single file", "file-input", "create.file", Ready),
item("Multiple files", "files", "create.multiple", Ready),
item("Separate PDFs", "files", "create.multiple_separate", Ready),
item("Images", "image", "create.images", Ready),
item("Clipboard", "copy-plus", "create.clipboard", Ready),
item("Blank page", "file-plus-2", "create.blank", Ready),
@ -457,12 +456,11 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
sections: &[ToolSection {
title: "Accessibility",
items: &[
item("Automatically tag PDF", "tag", "a11y.autotag", Ready),
item("Automatically tag PDF", "tag", "a11y.autotag", Planned("M12")),
item("Change reading options", "book-open", "a11y.reading_options", Ready),
item("Check for accessibility", "accessibility", "a11y.check", Ready),
item("Open accessibility report", "file-text", "a11y.report", Ready),
item("Add alternate text", "image", "a11y.alt_text", Ready),
item("Tags panel", "tag", "a11y.tags", Ready),
item("Fix reading order", "list-ordered", "a11y.reading_order", Planned("M12")),
],
}],

View File

@ -179,7 +179,6 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
c("create.multiple", "Create PDF from multiple files…", FILE, None, Nothing, "files"),
c("create.multiple_separate", "Create separate PDFs…", FILE, None, Nothing, "files"),
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"),
c("page.combine", "Combine files…", FILE, None, Nothing, "files"),
@ -216,11 +215,6 @@ 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"),
@ -316,7 +310,6 @@ pub const COMMANDS: &[CommandSpec] = &[
c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"),
c("export.text", "Export to text…", FILE, None, Copying, "type"),
c("export.docx", "Export to Word…", FILE, None, Copying, "file-text"),
c("export.pptx", "Export to PowerPoint…", FILE, None, Copying, "presentation"),
c("export.html", "Export to HTML…", FILE, None, Copying, "file-symlink"),
c("export.rtf", "Export to RTF…", FILE, None, Copying, "file-text"),
c("app.preferences", "Preferences…", EDIT, Some(Shortcut::cmd(",")), Nothing, "settings"),
@ -326,11 +319,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
c("a11y.tags", "Tags panel", None, None, Document, "tag"),
c("export.all_images", "Export all images…", FILE, None, Copying, "image"),
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),

View File

@ -121,11 +121,10 @@ impl Session {
}
}
/// Export a PDF ▸ Word, PowerPoint, HTML or RTF.
/// Export a PDF ▸ Word, HTML or RTF.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OfficeFormat {
Docx,
Pptx,
Html,
Rtf,
}
@ -134,7 +133,6 @@ impl OfficeFormat {
pub fn extension(self) -> &'static str {
match self {
OfficeFormat::Docx => "docx",
OfficeFormat::Pptx => "pptx",
OfficeFormat::Html => "html",
OfficeFormat::Rtf => "rtf",
}
@ -143,7 +141,6 @@ impl OfficeFormat {
pub fn from_extension(ext: &str) -> Option<OfficeFormat> {
match ext.to_ascii_lowercase().as_str() {
"docx" => Some(OfficeFormat::Docx),
"pptx" => Some(OfficeFormat::Pptx),
"html" | "htm" => Some(OfficeFormat::Html),
"rtf" => Some(OfficeFormat::Rtf),
_ => None,
@ -161,16 +158,6 @@ impl crate::Document {
.iter()
.enumerate()
.map(|(i, info)| {
// Export uses the visible page in points, y up. Raw PDF coordinates include
// CropBox offsets and precede /Rotate and /UserUnit.
let visible_rect = |r: [f64; 4]| {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64]
};
let crop_width = f64::from(info.crop[2] - info.crop[0]);
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
let blocks = pdfcraft_edit::reading_blocks(cos, i)
.unwrap_or_default()
.into_iter()
@ -179,12 +166,11 @@ impl crate::Document {
let f = b.base_font.to_ascii_lowercase();
pdfcraft_export::Block {
text: b.text,
rect: visible_rect(b.rect),
size: b.size * unit,
rect: b.rect,
size: b.size,
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
italic: f.contains("italic") || f.contains("oblique"),
color: b.color,
rotation: b.rotation - f64::from(info.rotation),
}
})
.collect();
@ -193,13 +179,7 @@ impl crate::Document {
.iter()
.filter_map(|im| {
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
Some(pdfcraft_export::Image {
ext: if ext == "jpg" { "jpg" } else { "png" },
bytes,
rect: visible_rect(im.rect),
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
rotation: im.matrix[1].atan2(im.matrix[0]).to_degrees() - f64::from(info.rotation),
})
Some(pdfcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect })
})
.collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
@ -207,13 +187,12 @@ impl crate::Document {
.collect()
}
/// The document as a Word, PowerPoint, HTML or RTF file.
/// The document as a Word, HTML or RTF file.
pub fn export_office(&self, format: OfficeFormat) -> Vec<u8> {
let pages = self.export_pages();
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
}

View File

@ -1,108 +0,0 @@
//! Copy Image: find the picture under a point and hand it to the clipboard, as pixels or as a
//! file in its original format. A picture drawn through grouped
//! Form artwork (as Word, InDesign and many scanners write them) is found inside the group.
use std::collections::HashSet;
use pdfcraft_cos::{Dict, Document as Cos, ObjRef, Object};
use crate::Document;
/// Clipboards copy the whole bitmap; past this many pixels a pixel copy is refused.
const MAX_COPY_PIXELS: u64 = 64_000_000;
/// How deep Form artwork is searched for its picture.
const MAX_FORM_DEPTH: usize = 12;
impl Document {
/// The topmost picture on `page` under user-space point `at`, as its index in
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option<usize> {
let editor = self.editor.as_ref()?;
let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]);
self.page_images(page).iter().rposition(|i| {
inside(&i.rect)
&& match (i.is_form, i.object) {
(false, Some(_)) => true,
(true, Some(form)) => picture_in_form(&editor.cos, form).is_some(),
_ => false,
}
})
}
/// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes):
/// a JPEG as stored in the PDF, otherwise a lossless PNG.
pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;
let object = img.object.ok_or("the image has no object")?;
let picture = if img.is_form { picture_in_form(&editor.cos, object).ok_or("this artwork holds no picture")? } else { object };
pdfcraft_create::image_file(&editor.cos, picture)
}
/// Copy Image: picture `index` on `page` at its own resolution, as straight (unpremultiplied)
/// RGBA pixels (width, height, pixels) for the clipboard. Soft masks become alpha.
pub fn page_image_rgba(&self, page: usize, index: usize) -> Result<(u32, u32, Vec<u8>), String> {
let (_, bytes) = self.page_image_copy_file(page, index)?;
let format = image::guess_format(&bytes).map_err(|e| e.to_string())?;
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes.as_slice()), format);
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_COPY_PIXELS * 4);
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|e| e.to_string())?;
let (w, h) = image::ImageDecoder::dimensions(&decoder);
if w == 0 || h == 0 {
return Err("the image is empty".into());
}
if u64::from(w) * u64::from(h) > MAX_COPY_PIXELS {
return Err(format!("the image is too large to copy ({w} × {h} pixels); use Save Image As"));
}
let rgba = image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?.into_rgba8();
Ok((rgba.width(), rgba.height(), rgba.into_raw()))
}
}
/// The largest raster image Form `form` draws through its resources (nested Forms included).
fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
let mut seen = HashSet::from([form]);
let mut best: Option<(u64, ObjRef)> = None;
let res = resources(cos, form)?;
collect(cos, &res, 0, &mut seen, &mut best);
best.map(|(_, r)| r)
}
fn resources(cos: &Cos, stream: ObjRef) -> Option<Dict> {
match &*cos.get(stream) {
Object::Stream(s) => s.dict.get(b"Resources").map(|r| cos.resolve(r)).and_then(|r| r.as_dict().cloned()),
_ => None,
}
}
fn collect(cos: &Cos, res: &Dict, depth: usize, seen: &mut HashSet<ObjRef>, best: &mut Option<(u64, ObjRef)>) {
if depth > MAX_FORM_DEPTH {
return;
}
let Some(xobjects) = res.get(b"XObject").map(|x| cos.resolve(x)).and_then(|x| x.as_dict().cloned()) else { return };
for (_, v) in xobjects.iter() {
let Some(r) = v.as_ref() else { continue };
if !seen.insert(r) {
continue;
}
let obj = cos.get(r);
let Object::Stream(s) = &*obj else { continue };
match s.dict.name(b"Subtype") {
Some(b"Image") => {
let px = |k: &[u8]| u64::try_from(s.dict.int(k).unwrap_or(0)).unwrap_or(0);
let size = px(b"Width").saturating_mul(px(b"Height"));
if size > 0 && best.is_none_or(|(b, _)| size > b) {
*best = Some((size, r));
}
}
Some(b"Form") => {
if let Some(inner) = resources(cos, r) {
collect(cos, &inner, depth + 1, seen, best);
}
}
_ => {}
}
}
}

View File

@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub static DATE_LANGUAGES: [DateLanguage; 22] = [
pub static DATE_LANGUAGES: [DateLanguage; 21] = [
DateLanguage {
code: "en",
name: "English",
@ -325,7 +325,7 @@ pub static DATE_LANGUAGES: [DateLanguage; 22] = [
"Νοεμβρίου",
"Δεκεμβρίου",
]),
months_short: ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μάι", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"],
months_short: ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μαΐ", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"],
days: ["Κυριακή", "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο"],
days_short: ["Κυρ", "Δευ", "Τρί", "Τετ", "Πέμ", "Παρ", "Σάβ"],
},
@ -338,28 +338,6 @@ pub static DATE_LANGUAGES: [DateLanguage; 22] = [
days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"],
days_short: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
},
DateLanguage {
code: "lt",
name: "Lietuvių",
months: ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"],
months_with_day: Some([
"sausio",
"vasario",
"kovo",
"balandžio",
"gegužės",
"birželio",
"liepos",
"rugpjūčio",
"rugsėjo",
"spalio",
"lapkričio",
"gruodžio",
]),
months_short: ["saus.", "vas.", "kov.", "bal.", "geg.", "birž.", "liep.", "rugpj.", "rugs.", "spal.", "lapkr.", "gruod."],
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
},
];
/// The date language for `code` (any case), or `None` when there's none.
@ -673,9 +651,6 @@ mod tests {
assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November"));
assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages");
assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one");
s.set_date_language(Some("LT")).unwrap();
assert_eq!(s.date_language(), Some("lt"));
assert_eq!(s.today_text(Some("dddd d mmmm"), None).as_deref(), Ok("antradienis 14 lapkričio"));
s.set_date_language(Some("IT")).unwrap();
assert_eq!(s.date_language(), Some("it"));
assert_eq!(s.today_text_for_pdf(None, None).as_deref(), Ok("martedì 14 novembre"), "Italian names and accents can be written into a PDF");

View File

@ -13,35 +13,25 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod actions;
pub mod autotag;
pub mod build_info;
pub mod camera;
pub mod camera_clean;
pub mod camera_edges;
pub mod catalog;
pub mod commands;
pub mod compare;
mod copy_image;
pub mod dates;
pub mod export;
pub mod js;
pub mod links;
pub mod ocr;
pub mod optimizer;
pub mod printcheck;
mod scan_colours;
pub mod signature_image;
pub mod signature_scan;
pub mod text_fonts;
mod text_preview;
mod upright_image;
pub mod xfa;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use text_preview::TextEditPreview;
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges};
pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
/// One file produced by a split: (1-based first page, last page, PDF bytes).
pub use pdfcraft_organize::LabelStyle;
@ -59,7 +49,7 @@ pub use pdfcraft_forms::{
};
pub use pdfcraft_a11y as a11y;
pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable};
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
@ -114,9 +104,6 @@ pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
}
pub use links::{
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
/// Comment geometry helpers (text-box line breaking) for frontends.
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
@ -150,7 +137,7 @@ pub type SplitPart = (usize, usize, Arc<Vec<u8>>);
use std::sync::Arc;
use pdfcraft_cos::{SaveOptions, write_full, write_incremental};
use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect};
use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, RenderConfig, RenderPool, inspect};
/// Stable identifier of an open document within a session.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -254,47 +241,6 @@ struct Editor {
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
/// that may change annotations; see [`AppearanceCache`].
appearances: AppearanceCache,
/// The text blocks last computed for one page, reused while the document is unchanged.
text_blocks: TextBlocksCache,
}
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they
/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long
/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit,
/// undo and save (each reopens the working file), so a stale entry never matches.
#[derive(Default)]
struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
/// The document state, the page (0-based) and its text blocks.
type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
impl Clone for TextBlocksCache {
fn clone(&self) -> Self {
// A poisoned cache only loses the cached blocks.
Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone())))
}
}
impl Editor {
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
let source = (!self.cos.is_modified()).then(|| self.cos.source_identity());
if let Some(source) = &source
&& let Ok(cache) = self.text_blocks.0.lock()
&& let Some((s, p, blocks)) = cache.as_ref()
&& s == source
&& *p == page
{
return Ok(blocks.clone());
}
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
if let Some(source) = source
&& let Ok(mut cache) = self.text_blocks.0.lock()
{
*cache = Some((source, page, blocks.clone()));
}
Ok(blocks)
}
}
pub struct Document {
@ -339,8 +285,6 @@ pub struct Document {
pub xfa: Option<XfaLayout>,
/// The parsed template of a laid-out XFA form, for its scripts.
xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
/// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]).
xfa_scripts_off: bool,
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
fonts_stale: bool,
@ -432,13 +376,6 @@ impl Document {
self.editor.as_ref().map(|e| a11y::check(&e.cos, options))
}
/// A script of this XFA form ran past its time limit and was abandoned, so the form's
/// scripts no longer run in this document (reopening it runs them again). The script's
/// error is in the JavaScript output; this is the state, for a viewer to show.
pub fn xfa_scripts_off(&self) -> bool {
self.xfa_scripts_off
}
/// PDF Optimizer ▸ Audit space usage.
pub fn audit_space(&self) -> Vec<optimize::SpaceUse> {
self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default()
@ -488,7 +425,7 @@ impl Document {
/// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based).
pub fn text_blocks(&self, page: usize) -> Vec<pdfcraft_edit::TextBlock> {
self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default()
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_blocks(&e.cos, page).ok()).unwrap_or_default()
}
/// Saved measurement annotations, calculated from their geometry and PDF scales, plus
@ -535,11 +472,6 @@ impl Document {
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
}
/// Reachable indirect structure tags, in document order.
pub fn tags(&self) -> Vec<a11y::TagInfo> {
self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default()
}
/// The edit that fixes `rule`, for the rules with an automatic fix: the document language
/// (`value` is the language), the title (`value` replaces it; else the current title or the
/// file name) and the tab order.
@ -742,12 +674,6 @@ impl Document {
self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default()
}
/// The file as last opened or saved, without unsaved edits: what an incremental save appends
/// to.
pub fn saved_bytes(&self) -> Arc<Vec<u8>> {
self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.cos.bytes().clone())
}
/// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
pub fn repair_log(&self) -> Vec<String> {
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
@ -775,74 +701,6 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u
write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone())
}
/// Fails when the renderer still draws text under an applied redaction mark.
///
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`,
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
/// the document as it was.
fn shown_under_redactions(
after: &pdfcraft_cos::Document,
marks: &[pdfcraft_redact::Mark],
pages: &[pdfcraft_render::PageInfo],
password: Option<&str>,
) -> Result<(), EditError> {
use pdfcraft_cos::Object;
use pdfcraft_redact::RedactError;
let mut marked: Vec<usize> = marks.iter().map(|m| m.page).collect();
marked.sort_unstable();
marked.dedup();
if marked.is_empty() {
return Ok(());
}
let mut shown = after.clone();
let model = pdfcraft_model::pages(&shown);
for &pi in &marked {
let Some(page) = model.get(pi) else { continue };
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
let kept: Vec<Object> =
list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect();
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
}
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config);
let mut residue = 0;
for &pi in &marked {
let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
// A page that cannot be read back cannot be confirmed clean.
let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) };
let rects: Vec<[f64; 4]> = marks
.iter()
.filter(|m| m.page == pi)
.flat_map(|m| m.rects.iter())
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])
.collect();
for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) {
let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from);
let area = (x1 - x0) * (y1 - y0);
if area.is_nan() || area <= 0.0 {
continue;
}
// Redaction itself takes any glyph a fifth under a mark; one that is half under it
// and still drawn was not where redaction thought it was.
let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area;
if rects.iter().any(under) {
residue += 1;
}
}
}
if residue > 0 {
return Err(RedactError::Residue(residue).into());
}
Ok(())
}
fn render_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
}
@ -882,14 +740,6 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes:
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names
/// separate (old fully-qualified name → new one), in order across the sources.
#[derive(Debug)]
pub struct Combined {
pub bytes: Arc<Vec<u8>>,
pub renamed_fields: Vec<(String, String)>,
}
/// A bookmark as the bookmark list shows it.
#[derive(Clone, Debug, PartialEq)]
pub struct BookmarkRow {
@ -1141,8 +991,6 @@ pub enum Edit {
bytes: Arc<Vec<u8>>,
pages: Option<Vec<usize>>,
at: usize,
/// How a form field whose name the document already uses is kept separate.
field_conflicts: pdfcraft_organize::FieldConflicts,
},
/// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level).
AddBookmark {
@ -1330,26 +1178,12 @@ pub enum Edit {
},
/// Document Properties ▸ Initial View (and the language and binding).
SetInitialView(Box<InitialView>),
/// Build a tagged structure tree for an untagged, text-only document.
AutoTagText,
/// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is
/// the figure element's object number (from `Document::figures`).
SetAltText {
figure: u32,
alt: Option<String>,
},
/// Change one property on a reachable indirect structure tag.
SetTagProperty {
object: pdfcraft_cos::ObjRef,
property: a11y::TagProperty,
value: Option<String>,
},
/// Move a structure tag before or after a direct sibling in reading order.
MoveTag {
object: pdfcraft_cos::ObjRef,
anchor: pdfcraft_cos::ObjRef,
placement: a11y::TagPlacement,
},
/// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact.
MarkDecorative {
figure: u32,
@ -1418,11 +1252,6 @@ pub enum Edit {
/// Formatting changes (font, size, colour, alignment); default keeps the paragraph's.
style: pdfcraft_edit::BlockStyle,
},
/// An explicit open-licensed full font program, completing subsets or selected by a user.
RegisterTextFont {
page: usize,
bytes: Arc<Vec<u8>>,
},
/// Order tabs manually: move a field one place earlier or later on its page.
MoveInTabOrder {
name: String,
@ -1609,7 +1438,6 @@ impl std::fmt::Display for Label {
/// [`BATCH_LABELS`]).
pub const EDIT_LABELS: &[&str] = &[
"Rotate page",
"Change tag properties",
"Rotate pages",
"Delete page",
"Delete pages",
@ -1629,8 +1457,6 @@ pub const EDIT_LABELS: &[&str] = &[
"Change Keywords",
"Change {key}",
"Insert pages from {name}",
"Insert pages from {name} — 1 field renamed",
"Insert pages from {name} — fields renamed",
"Add bookmark",
"Rename bookmark",
"Delete bookmark",
@ -1701,7 +1527,6 @@ pub const EDIT_LABELS: &[&str] = &[
"Delete document JavaScript",
"Edit document JavaScript",
"Edit text",
"Load document font",
"Move object",
"Move objects",
"Move image",
@ -1813,10 +1638,7 @@ impl Edit {
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
Edit::SetInitialView(_) => Label::of("Change initial view"),
Edit::AutoTagText => Label::of("Automatically tag text"),
Edit::SetAltText { .. } => Label::of("Set alternate text"),
Edit::SetTagProperty { .. } => Label::of("Change tag properties"),
Edit::MoveTag { .. } => Label::of("Move object"),
Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"),
Edit::AddOcrText { .. } => Label::of("Recognize text"),
Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"),
@ -1827,7 +1649,6 @@ impl Edit {
Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"),
Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"),
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"),
Edit::RegisterTextFont { .. } => Label::of("Load document font"),
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
Edit::EditPageImage { change, .. } => Label::of(match change {
ImageEdit::Move(_) => "Move image",
@ -2002,10 +1823,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
| Edit::SetInitialView(_)
| Edit::AutoTagText
| Edit::SetAltText { .. }
| Edit::SetTagProperty { .. }
| Edit::MoveTag { .. }
| Edit::MarkDecorative { .. }
| Edit::AddOcrText { .. }
| Edit::SetDocumentScript { .. }
@ -2013,7 +1831,6 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::ConvertPdfA { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::RegisterTextFont { .. }
| Edit::EditPageImage { .. }
| Edit::Flatten { .. }
| Edit::FlattenFillSign => {
@ -2046,9 +1863,6 @@ struct EditCtx {
today: (i64, u32, u32),
seed: u64,
count: u64,
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
/// log and the undo label.
renamed_fields: Vec<(String, String)>,
}
impl EditCtx {
@ -2073,7 +1887,6 @@ impl EditCtx {
today: (1970, 1, 1),
seed,
count: 0,
renamed_fields: Vec::new(),
}
}
@ -2130,14 +1943,13 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
}
Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?,
Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => {
Edit::InsertPagesFrom { name, bytes, pages, at } => {
let src = open_source(name, bytes)?;
let pages = match pages {
Some(p) => p.clone(),
None => (0..pdfcraft_organize::page_count(&src)?).collect(),
};
let outcome = pdfcraft_organize::import_pages_with(doc, &src, &pages, *at, *field_conflicts)?;
cx.renamed_fields = outcome.renamed_fields;
pdfcraft_organize::import_pages(doc, &src, &pages, *at)?;
}
Edit::AddBookmark { parent, index, title, page } => {
pdfcraft_organize::add_bookmark(doc, parent, *index, title, *page)?;
@ -2167,7 +1979,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let src = mark_source(doc, file)?;
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
let natural = rect[2] == rect[0] && rect[3] == rect[1];
let rotation = pdfcraft_organize::page_rotation(doc, *page)?;
let rotation = if natural && src.image { pdfcraft_organize::page_rotation(doc, *page)? } else { 0 };
let rect = if natural {
let k = (200.0 / sw.max(sh)).min(1.0);
let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) };
@ -2277,19 +2089,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?,
Edit::AutoTagText => {
a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::SetAltText { figure, alt } => {
let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::SetTagProperty { object, property, value } => {
a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::MoveTag { object, anchor, placement } => {
a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::EditTextLine { page, line, text } => {
pdfcraft_edit::replace_line(doc, *page, *line, text)?;
}
@ -2314,15 +2117,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
let c = match change {
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
ImageEdit::Rotate(q) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, *q, false, false)),
ImageEdit::Flip { horizontal } => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal))
}
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
img.visible_rect.unwrap_or(img.rect),
0,
*horizontal,
!*horizontal,
)),
ImageEdit::Replace { name, bytes } => {
if img.is_form {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
@ -2339,9 +2137,6 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::EditTextBlock { page, block, text, style } => {
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
}
Edit::RegisterTextFont { page, bytes } => {
pdfcraft_edit::text::register_text_font(doc, *page, bytes)?;
}
Edit::MarkDecorative { figure } => {
let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?;
@ -2687,34 +2482,6 @@ pub struct Session {
date_language: Option<String>,
}
/// Immutable session state needed while a document is prepared away from its owning thread.
#[derive(Clone)]
pub struct OpenContext {
now: Option<i64>,
js_off: bool,
trust: Arc<TrustStore>,
}
/// A fully prepared document that has not yet been registered in a session.
pub struct PreparedDocument {
document: Document,
}
impl OpenContext {
/// Prepare a document without mutating the session that supplied this context.
pub fn prepare_open(
&self,
name: impl Into<String>,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
let name = name.into();
guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password))
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
}
}
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout, String> {
let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
@ -3007,45 +2774,13 @@ impl Session {
self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string())
}
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
pub fn open_context(&self) -> OpenContext {
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() }
}
/// Prepare a document without adding it to this session.
pub fn prepare_open(
&self,
name: impl Into<String>,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
self.open_context().prepare_open(name, path, bytes, password)
}
/// Register a prepared document. This is the only open step that mutates the session.
pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId {
self.next_id += 1;
let id = DocId(self.next_id);
let mut document = prepared.document;
document.id = id;
self.docs.push(document);
id
}
/// Synchronous convenience API used by the CLI and automation.
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
let prepared = self.prepare_open(name, path, bytes, password)?;
Ok(self.commit_open(prepared))
let name = name.into();
guard(|| self.open_unguarded(name, path, bytes, password))
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
}
fn prepare_open_unguarded(
context: &OpenContext,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
fn open_unguarded(&mut self, name: String, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password));
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
// files, so give it the user password that owner authentication recovers.
@ -3064,7 +2799,7 @@ impl Session {
// A dynamic XFA form is a shell around an XML template; lay the template out into real
// pages and fields so the rest of the engine works on it. The original bytes stay: the
// laid-out form is one appended revision.
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
// Write `work` as one more revision and reopen it: what the document then is.
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
@ -3133,7 +2868,7 @@ impl Session {
_ => None,
};
let (bytes, info, cos) = match (&xfa, cos) {
(Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() {
(Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() {
Some(tpl) => {
let mut work = cos.clone();
let pages = info.pages.len();
@ -3176,22 +2911,23 @@ impl Session {
};
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
// A script that ran away on open turns the form's scripts off for this document.
document.xfa_template = if ran_away { None } else { xfa_template };
document.js_output.append(script_output);
if ran_away {
document.xfa_scripts_off = true;
document.js_output.errors.push(XFA_SCRIPTS_OFF.into());
let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?;
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
// A script that ran away on open turns the form's scripts off for this document.
d.xfa_template = if ran_away { None } else { xfa_template };
d.js_output.append(script_output);
if ran_away {
d.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
note_warnings(&mut d.xfa_warnings, &xfa_warnings);
}
note_warnings(&mut document.xfa_warnings, &xfa_warnings);
Ok(PreparedDocument { document })
Ok(id)
}
/// The last step of opening: build the document record and register it.
#[allow(clippy::too_many_arguments)]
fn build_document(
trust: &Arc<TrustStore>,
fn push_document(
&mut self,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
@ -3200,22 +2936,12 @@ impl Session {
render_password: Option<String>,
password: Option<&str>,
xfa: Option<XfaLayout>,
) -> Document {
) -> Result<DocId, OpenError> {
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
(
Some(Editor {
cos,
undo: Vec::new(),
redo: Vec::new(),
keys,
appearances: AppearanceCache::default(),
text_blocks: TextBlocksCache::default(),
}),
None,
)
(Some(Editor { cos, undo: Vec::new(), redo: Vec::new(), keys, appearances: AppearanceCache::default() }), None)
}
Ok(Err(e)) => (None, Some(e.to_string())),
Err(_) => (None, Some("the document structure could not be read for editing".into())),
@ -3250,9 +2976,11 @@ impl Session {
.unwrap_or_default();
let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default();
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
Document {
id: DocId(0),
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default();
self.next_id += 1;
let id = DocId(self.next_id);
self.docs.push(Document {
id,
name,
path,
bytes,
@ -3269,18 +2997,18 @@ impl Session {
added,
links,
signatures,
trust: trust.clone(),
trust: self.trust.clone(),
sig_cache,
editor,
config,
js_output: Default::default(),
xfa,
xfa_template: None,
xfa_scripts_off: false,
fonts_stale: false,
xfa_warnings: Vec::new(),
edit_notices: Vec::new(),
}
});
Ok(id)
}
fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> {
@ -3308,23 +3036,6 @@ impl Session {
if let Some(p) = editor.cos.permissions() {
check_permission(&edit, &p)?;
}
// Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it
// in the paper's colour and draw the new text in the ink's.
let edit = match edit {
Edit::EditTextBlock { page, block, text, mut style } => {
// Only invisible text is sampled; the blocks are usually cached from opening it.
if style.cover.is_none()
&& let Some(info) = doc.info.pages.get(page)
&& let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned())
&& let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten()
{
style.cover = Some(paper);
style.color = style.color.or(Some(ink));
}
Edit::EditTextBlock { page, block, text, style }
}
other => other,
};
// Nothing to bake: leave undo history and the dirty flag alone.
if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) {
return Ok(());
@ -3333,46 +3044,9 @@ impl Session {
if !js_off && uses_scripts(&edit) {
cx.js = Some(js::JsRunner::new(&next, &name));
}
cx.renamed_fields.clear();
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
guard(|| run_edit(&mut next, &edit, &mut cx))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
// Redaction removed what its own interpreter places under the marks. The marks were drawn
// over the page as the renderer shows it, so the renderer has the last word.
if let Edit::ApplyRedactions { pages } = &edit {
let marks: Vec<pdfcraft_redact::Mark> =
pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect();
guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref()))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
}
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
// scripts were left alone) and a count in the undo step's label.
let renamed_note = |renamed: &[(String, String)]| {
(!renamed.is_empty()).then(|| {
let shown: Vec<String> = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect();
let more = renamed.len().saturating_sub(shown.len());
let more = if more > 0 { format!(", and {more} more") } else { String::new() };
format!(
"inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten",
renamed.len(),
if renamed.len() == 1 { "" } else { "s" },
shown.join(", ")
)
})
};
if let Some(note) = renamed_note(&cx.renamed_fields) {
next.note_repair(note);
}
// Only Insert from File renames fields; its label says so in a wording the interface
// translates (the count lives in the repair note).
let label = match (&edit, cx.renamed_fields.len()) {
(_, 0) => edit.label(),
(Edit::InsertPagesFrom { name, .. }, n) => Label::with(
if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" },
name.as_str(),
),
_ => edit.label(),
};
// An XFA form's scripts answer the change: exit and validate scripts of the field, then
// every calculation (and a reset recalculates). They read the data, so it is brought up
// to date first.
@ -3433,7 +3107,7 @@ impl Session {
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
let scope = scope_of(&edit);
editor.undo.push((label, previous, previous_keys, scope));
editor.undo.push((edit.label(), previous, previous_keys, scope));
if editor.undo.len() > MAX_UNDO {
editor.undo.remove(0);
}
@ -3450,9 +3124,7 @@ impl Session {
ed.keys = keys;
}
Self::adopt_keys(doc);
if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) {
Self::show_released(doc);
}
let _ = guard(|| Self::refresh(doc));
return Err(e);
}
doc.dirty = true;
@ -3467,7 +3139,6 @@ impl Session {
doc.js_output.append(cx.xfa_out);
if cx.xfa_ran_away {
doc.xfa_template = None;
doc.xfa_scripts_off = true;
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
Ok(())
@ -3482,7 +3153,7 @@ impl Session {
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.redo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
Self::refresh_scoped(doc, scope)?;
doc.dirty = true;
doc.generation += 1;
Ok(label)
@ -3497,7 +3168,7 @@ impl Session {
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.undo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
Self::refresh_scoped(doc, scope)?;
doc.dirty = true;
doc.generation += 1;
Ok(label)
@ -3522,10 +3193,6 @@ impl Session {
if scope == Scope::Content {
return Self::refresh_content(doc, None);
}
// Metadata edits keep the renderer, which holds the working file anyway.
if scope != Scope::Metadata {
Self::release_working(doc);
}
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// Comment edits, and form edits (a script may change annotation properties), may change
// annotation dictionaries, invalidating cached appearances.
@ -3618,7 +3285,6 @@ impl Session {
/// from undo/redo, re-reads both: always correct, rarely paid). The cached display
/// appearances are kept: content edits leave every annotation dictionary alone.
fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> {
Self::release_working(doc);
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
@ -3651,28 +3317,6 @@ impl Session {
Ok(())
}
/// Let go of the working file before the next one is written (gap 13). The working file is
/// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the
/// working bytes, the display bytes and the renderer) while writing the new one held three
/// copies at once; this keeps two. Until the refresh finishes, the working and display bytes
/// are the original's (already held by the editor, so free) and the renderer draws nothing.
/// A failed edit rolls back and refreshes again, which replaces all three; where a refresh
/// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes
/// from the editor, never from these.
fn release_working(doc: &mut Document) {
let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return };
// Dropping the pool stops its workers; each lets go of the file when it exits.
doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone());
doc.display = original.clone();
doc.bytes = original;
}
/// After a refresh failed: draw the display bytes it left (the original, once released),
/// so the page still shows a document.
fn show_released(doc: &mut Document) {
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
fn refresh(doc: &mut Document) -> Result<(), EditError> {
// A full refresh may follow structural or annotation changes: cached appearances
@ -3680,7 +3324,6 @@ impl Session {
if let Some(editor) = doc.editor.as_mut() {
editor.appearances.clear();
}
Self::release_working(doc);
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
@ -3757,7 +3400,7 @@ impl Session {
}
doc.dirty = false;
doc.generation += 1;
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
Self::refresh(doc)
}
/// Enter the permissions (owner) password of a document opened with its user password,
@ -3815,7 +3458,7 @@ impl Session {
Self::adopt_keys(doc);
doc.dirty = false;
doc.generation += 1;
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
Self::refresh(doc)
}
/// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open
@ -4016,28 +3659,6 @@ impl Session {
/// [`Self::combine_ranges`] with the password each encrypted source is opened with, by
/// position (missing or `None`: no password). The result is not encrypted.
pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
}
/// The pre-flight over byte sources: what combining them, in order, would rename in each one
/// (whole files; page ranges are the combine's to apply). A source that cannot be opened
/// previews as nothing — its row already says why it cannot be combined.
pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec<Vec<(String, String)>> {
let docs: Vec<Option<pdfcraft_cos::Document>> =
sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect();
let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(|d| d.as_ref()).collect();
let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter();
// One preview per source, in step with it: a file that cannot be opened has none.
docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect()
}
/// [`Self::combine_unlocked`] reporting what the conflict resolution renamed.
pub fn combine_unlocked_reporting(
&self,
sources: &[CombineSource],
passwords: &[Option<&str>],
field_conflicts: pdfcraft_organize::FieldConflicts,
) -> Result<Combined, EditError> {
let docs = sources
.iter()
.enumerate()
@ -4058,9 +3679,8 @@ impl Session {
}
let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> =
sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect();
let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?;
let bytes = self.write_new(&out.doc)?;
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
let out = pdfcraft_organize::combine_selected(&named)?;
self.write_new(&out)
}
/// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one
@ -4069,17 +3689,7 @@ impl Session {
/// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes
/// and password; a source without a key is a file of its own.
pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
}
/// [`Self::combine_grouped`] reporting what the conflict resolution renamed.
pub fn combine_grouped_reporting(
&self,
sources: &[CombineSource],
groups: &[u64],
passwords: &[Option<&str>],
field_conflicts: pdfcraft_organize::FieldConflicts,
) -> Result<Combined, EditError> {
// Each file opened once: `file[i]` is source i's place in `docs`.
let (mut keys, mut docs, mut file): (Vec<Option<u64>>, Vec<pdfcraft_cos::Document>, Vec<usize>) = (Vec::new(), Vec::new(), Vec::new());
for (i, (name, bytes, _)) in sources.iter().enumerate() {
let key = groups.get(i).copied();
@ -4113,9 +3723,8 @@ impl Session {
let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) };
runs.push((*at, name.as_str(), d, p.as_deref()));
}
let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?;
let bytes = self.write_new(&out.doc)?;
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
let out = pdfcraft_organize::combine_grouped(&runs)?;
self.write_new(&out)
}
/// New PDF bytes containing copies of `pages` of the document (Extract Pages).
@ -4329,24 +3938,6 @@ impl Session {
true
}
/// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether
/// anything changed; callers must drop cached rasters and text for the document.
pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool {
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false };
if doc.config.reading_colors == colors {
return false;
}
doc.config.reading_colors = colors;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.generation += 1;
true
}
/// The document's reading-colours setting; `None` when no such document is open.
pub fn reading_colors(&self, id: DocId) -> Option<ReadingColors> {
Some(self.get(id)?.config.reading_colors)
}
/// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment
/// with its number, author, type, date, text and replies, grouped by page.
pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result<Arc<Vec<u8>>, EditError> {

View File

@ -2,7 +2,6 @@
//! About dialog, the home screen, the CLI and the README agree.
use icu_properties::props::Script;
use std::path::{Path, PathBuf};
/// The app's name in ArtCraft URLs (`getartcraft.com/apps/{APP}`, `github.com/storytold/{APP}`).
pub const APP: &str = "pdfcraft";
@ -129,111 +128,6 @@ pub fn document_url(raw: &str) -> Result<String, BlockedLink> {
Ok(url.to_string())
}
/// Maximum decoded GoToR file-spec size retained by inspection and accepted by the resolver.
pub const MAX_REMOTE_DOCUMENT_PATH_BYTES: usize = 4096;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemoteDocumentPathError {
Empty,
TooLong,
Hidden,
UrlOrNetwork,
MissingSourcePath,
/// The target doesn't end in `.pdf`.
NotPdf,
/// The target is a directory, device, FIFO or socket rather than a regular file.
NotAFile,
/// The target is larger than [`MAX_REMOTE_DOCUMENT_BYTES`].
TooLarge,
/// The target couldn't be read.
Read(String),
}
impl std::fmt::Display for RemoteDocumentPathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => f.write_str("the file specification is empty"),
Self::TooLong => f.write_str("the file specification is too long"),
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
Self::NotAFile => f.write_str("the linked path is not a regular file"),
Self::TooLarge => f.write_str("the linked file is too large to open"),
Self::Read(e) => write!(f, "the linked file couldn't be read: {e}"),
}
}
}
/// Whether the original GoToR file specification is absolute on this platform.
/// Check this before resolving relative paths against the source document. This does no I/O;
/// the resolver must still reject URL/network paths and malformed file specifications.
pub fn remote_document_path_is_absolute(raw: &str) -> bool {
Path::new(raw).is_absolute()
}
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
if raw.is_empty() || raw.trim().is_empty() {
return Err(RemoteDocumentPathError::Empty);
}
if raw.len() > MAX_REMOTE_DOCUMENT_PATH_BYTES {
return Err(RemoteDocumentPathError::TooLong);
}
if raw.chars().any(hides_text) {
return Err(RemoteDocumentPathError::Hidden);
}
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
let path = Path::new(raw);
let drive_absolute = remote_document_path_is_absolute(raw);
if let Some((scheme, _)) = raw.split_once(':') {
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
let windows_drive = scheme.len() == 1 && drive_absolute;
if valid_scheme && !windows_drive {
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
}
if drive_absolute {
return Ok(path.to_path_buf());
}
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
Ok(parent.join(path))
}
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
pub const MAX_REMOTE_DOCUMENT_BYTES: u64 = 1 << 30;
/// Read the PDF a GoToR link resolved to. The path comes from an untrusted document, so only a
/// regular `.pdf` file of at most [`MAX_REMOTE_DOCUMENT_BYTES`] is read: a directory, a device such
/// as `/dev/zero` or a FIFO is refused before it is opened, and a file that grows past the cap
/// while it is read is refused too.
pub fn read_remote_document(path: &Path) -> Result<Vec<u8>, RemoteDocumentPathError> {
use std::io::Read;
let is_pdf = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"));
if !is_pdf {
return Err(RemoteDocumentPathError::NotPdf);
}
let meta = std::fs::metadata(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
if !meta.is_file() {
return Err(RemoteDocumentPathError::NotAFile);
}
if meta.len() > MAX_REMOTE_DOCUMENT_BYTES {
return Err(RemoteDocumentPathError::TooLarge);
}
let file = std::fs::File::open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
file.take(MAX_REMOTE_DOCUMENT_BYTES.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
if u64::try_from(bytes.len()).map_or(true, |n| n > MAX_REMOTE_DOCUMENT_BYTES) {
return Err(RemoteDocumentPathError::TooLarge);
}
Ok(bytes)
}
/// Email-address parameters that make an email app read a file from the user's computer: Evolution,
/// KMail and Claws Mail attach the file named by `attach` (Evolution and KMail also by
/// `attachment`), and Claws Mail reads an `insert` file into the message body. Names are matched as
@ -445,81 +339,6 @@ fn mixes_scripts(label: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn remote_documents_must_be_regular_pdf_files() {
use super::{RemoteDocumentPathError as E, read_remote_document as read};
let dir = std::env::temp_dir().join(format!("pdfcraft-gotor-read-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("folder.pdf")).unwrap();
std::fs::write(dir.join("doc.PDF"), b"%PDF-1.7\n").unwrap();
std::fs::write(dir.join("notes.txt"), b"text").unwrap();
assert_eq!(read(&dir.join("doc.PDF")).as_deref(), Ok(&b"%PDF-1.7\n"[..]));
assert_eq!(read(&dir.join("folder.pdf")), Err(E::NotAFile), "a directory is refused");
assert_eq!(read(&dir.join("notes.txt")), Err(E::NotPdf));
assert_eq!(read(&dir), Err(E::NotPdf));
#[cfg(unix)]
{
// Sparse: the size guard must reject this without allocating or reading a GiB.
let large = dir.join("large.pdf");
std::fs::File::create(&large).unwrap().set_len(super::MAX_REMOTE_DOCUMENT_BYTES + 1).unwrap();
assert_eq!(read(&large), Err(E::TooLarge));
}
assert!(matches!(read(&dir.join("missing.pdf")), Err(E::Read(_))));
#[cfg(unix)]
{
// A device (like /dev/zero, which never ends) and a FIFO (which blocks) are never opened.
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
assert_eq!(read(&dir.join("zero.pdf")), Err(E::NotAFile));
let fifo = dir.join("pipe.pdf");
if std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
assert_eq!(read(&fifo), Err(E::NotAFile));
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remote_document_absolute_classification_uses_the_original_platform_path() {
use super::remote_document_path_is_absolute as absolute;
for relative in ["target.pdf", "../appendix/target.pdf", "folder/target.pdf"] {
assert!(!absolute(relative), "{relative}");
}
#[cfg(unix)]
{
assert!(absolute("/manual/target.pdf"));
assert!(!absolute(r"C:\manual\target.pdf"));
}
#[cfg(windows)]
{
assert!(absolute(r"C:\manual\target.pdf"));
assert!(absolute("C:/manual/target.pdf"));
assert!(!absolute(r"C:target.pdf"), "drive-relative is not absolute");
assert!(!absolute(r"\manual\target.pdf"), "rooted without a drive is not absolute");
}
}
#[test]
fn remote_document_paths_are_local_and_source_relative() {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
use std::path::Path;
let source = Path::new("/docs/chapter/source.pdf");
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
}
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
{
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
}
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
}
#[test]
fn urls_follow_the_artcraft_scheme() {
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));

View File

@ -1,474 +0,0 @@
//! Print readiness, verification only.
//!
//! Measures what a press looks at and changes nothing: effective image resolution where each
//! image is drawn, the page boxes (media, crop, trim, bleed), the output intent, fonts that
//! aren't embedded, and the existing PDF/A-2b result. There is no fixup and no generated colour
//! profile. A screen profile such as sRGB is reported as a screen profile, not accepted as a
//! press condition.
use pdfcraft_cos::Document;
use pdfcraft_edit::PageImage;
use pdfcraft_preflight::{Issue, Level};
use crate::Document as OpenDocument;
/// Below this, ordinary sheet printing cannot hold the picture. Large-format work sometimes can.
const FAIL_PPI: f64 = 150.0;
/// Ordinary close-up print. Between this and [`FAIL_PPI`] the picture is usable but soft.
const WARN_PPI: f64 = 300.0;
/// Less than this past the trim, on the tightest side, is not a normal 3 mm bleed.
const MIN_BLEED_MM: f64 = 2.0;
/// One thing a printer should see, without the file having been rewritten.
#[derive(Clone, Debug, PartialEq)]
pub struct Finding {
pub id: &'static str,
pub severity: &'static str,
pub message: String,
/// 1-based page, when the finding is about one page.
pub page: Option<u32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PageBoxes {
pub page: u32,
pub media_mm: [f64; 2],
pub crop_mm: [f64; 2],
pub trim_mm: [f64; 2],
pub bleed_mm: [f64; 2],
pub trim_set: bool,
pub bleed_set: bool,
/// Left, bottom, right, top, in millimetres, from the trim out to the bleed.
pub bleed_margin_mm: [f64; 4],
}
#[derive(Clone, Debug, PartialEq)]
pub struct DrawnImage {
pub page: u32,
pub name: String,
pub ppi: f64,
pub pixels: [u32; 2],
pub size_mm: [f64; 2],
}
#[derive(Clone, Debug, PartialEq)]
pub struct PrintReadiness {
pub pages: Vec<PageBoxes>,
pub images: Vec<DrawnImage>,
pub output_intents: Vec<String>,
pub fonts_not_embedded: Vec<String>,
pub pdfa_issues: Vec<Issue>,
pub findings: Vec<Finding>,
/// Image placements whose resolution couldn't be measured (a degenerate or non-finite
/// placement matrix). They are neither passed nor failed.
pub resolution_unknown: usize,
}
impl PrintReadiness {
/// A plain-text report. The document is unchanged.
pub fn text(&self) -> String {
let mut out = String::from("Print readiness (verification only; the file was not changed)\n");
if self.output_intents.is_empty() {
out.push_str("Output intent: none\n");
} else {
out.push_str(&format!("Output intent: {}\n", self.output_intents.join(", ")));
}
for page in &self.pages {
if !page.trim_set {
out.push_str(&format!("Page {}: media {} mm, no TrimBox\n", page.page, size_mm(page.media_mm)));
continue;
}
out.push_str(&format!(
"Page {}: media {} mm, trim {} mm, bleed margin {} mm{}\n",
page.page,
size_mm(page.media_mm),
size_mm(page.trim_mm),
min_mm(page.bleed_margin_mm),
if page.bleed_set { "" } else { " (no BleedBox)" },
));
}
for image in &self.images {
out.push_str(&format!(
"Image {} on page {}: {:.0} ppi, {} × {} px, {} mm\n",
image.name,
image.page,
image.ppi,
image.pixels[0],
image.pixels[1],
size_mm(image.size_mm),
));
}
if self.resolution_unknown > 0 {
out.push_str(&format!("Images with unknown resolution: {}\n", self.resolution_unknown));
}
if self.fonts_not_embedded.is_empty() {
out.push_str("Fonts: every referenced font is embedded, or the file has none\n");
} else {
out.push_str(&format!("Fonts not embedded: {}\n", self.fonts_not_embedded.join(", ")));
}
let level = Level::A2b.label();
if self.pdfa_issues.is_empty() {
out.push_str(&format!("{level}: no problems found by the structural check\n"));
} else {
out.push_str(&format!("{level}: {} problem{}\n", self.pdfa_issues.len(), if self.pdfa_issues.len() == 1 { "" } else { "s" }));
for issue in &self.pdfa_issues {
match issue.page {
Some(p) => out.push_str(&format!("- page {}: {}\n", p + 1, issue.message)),
None => out.push_str(&format!("- {}\n", issue.message)),
}
}
}
if self.findings.is_empty() {
out.push_str("Print checks: no stop for image resolution or bleed\n");
} else {
out.push_str("Print checks:\n");
for finding in &self.findings {
out.push_str(&format!("- [{}] {}\n", finding.severity, finding.message));
}
}
out.push_str("Not checked: PDF/X, overprint, total area coverage, spot colours, transparency flattening.\n");
out
}
}
impl OpenDocument {
/// A print-readiness report. Reads the file and does not edit it.
pub fn print_readiness(&self) -> PrintReadiness {
match &self.editor {
Some(editor) => inspect(&editor.cos),
None => PrintReadiness {
pages: Vec::new(),
images: Vec::new(),
output_intents: Vec::new(),
fonts_not_embedded: Vec::new(),
pdfa_issues: Vec::new(),
resolution_unknown: 0,
findings: vec![Finding {
id: "document",
severity: "info",
message: "The document has no object graph to measure.".into(),
page: None,
}],
},
}
}
}
fn inspect(doc: &Document) -> PrintReadiness {
let declared = pdfcraft_preflight::declared(doc);
let pdfa_issues = pdfcraft_preflight::verify(doc, Level::A2b);
let fonts_not_embedded = pdfa_issues
.iter()
.filter(|issue| issue.pattern.contains("isn't embedded") || issue.pattern == "A font isn't embedded")
.map(|issue| issue.value.clone().unwrap_or_else(|| "unnamed font".into()))
.collect();
let model_pages = pdfcraft_model::pages(doc);
let pages = page_reports(doc, &model_pages);
let (images, resolution_unknown) = image_reports(doc, &model_pages);
let mut findings = Vec::new();
// Without a TrimBox the trim, the bleed and the crop are the same box, so there is no bleed to
// measure. That is a property of the document, not a fault on every page: report it once.
let untrimmed = pages.iter().filter(|page| !page.trim_set).count();
if untrimmed > 0 {
findings.push(Finding {
id: "page-trim",
severity: "info",
message: if untrimmed == pages.len() {
"No page has a TrimBox, so bleed can't be measured.".to_string()
} else {
format!("{untrimmed} of {} pages have no TrimBox, so their bleed can't be measured.", pages.len())
},
page: None,
});
}
for page in pages.iter().filter(|page| page.trim_set) {
let tight = page.bleed_margin_mm.into_iter().fold(f64::INFINITY, f64::min);
if tight < MIN_BLEED_MM {
findings.push(Finding {
id: "page-bleed",
severity: "warn",
message: format!(
"Page {} has {:.1} mm of bleed past the trim on its tightest side (a press usually wants about 3 mm). Nothing was added.",
page.page,
tight.max(0.0)
),
page: Some(page.page),
});
}
}
for image in &images {
if image.ppi < FAIL_PPI {
findings.push(Finding {
id: "image-ppi",
severity: "fail",
message: format!(
"Image {} on page {} is drawn at {:.0} ppi, below {:.0}. Enlarging it would not add detail.",
image.name, image.page, image.ppi, FAIL_PPI
),
page: Some(image.page),
});
} else if image.ppi < WARN_PPI {
findings.push(Finding {
id: "image-ppi",
severity: "warn",
message: format!(
"Image {} on page {} is drawn at {:.0} ppi, below {:.0} for close-up print.",
image.name, image.page, image.ppi, WARN_PPI
),
page: Some(image.page),
});
}
}
if declared.output_intents.iter().any(|intent| is_screen_profile(intent))
&& !declared.output_intents.iter().any(|intent| is_press_profile(intent))
{
findings.push(Finding {
id: "output-intent",
severity: "warn",
message: format!(
"The output intent ({}) is a screen profile. A press condition was not added, because inventing one would retint the file.",
declared.output_intents.join(", ")
),
page: None,
});
}
if resolution_unknown > 0 {
findings.push(Finding {
id: "image-ppi-unknown",
severity: "info",
message: format!(
"The resolution of {resolution_unknown} image placement{} couldn't be measured (its placement matrix is degenerate or not finite).",
if resolution_unknown == 1 { "" } else { "s" }
),
page: None,
});
}
findings.sort_by(|a, b| a.page.cmp(&b.page).then(a.id.cmp(b.id)).then(a.message.cmp(&b.message)));
PrintReadiness { pages, images, output_intents: declared.output_intents, fonts_not_embedded, pdfa_issues, findings, resolution_unknown }
}
fn page_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> Vec<PageBoxes> {
let boxes = pdfcraft_organize::page_boxes(doc).unwrap_or_default();
pages
.iter()
.enumerate()
.map(|(i, page)| {
let row = boxes.get(i).copied().unwrap_or([[0.0; 4]; 5]);
let trim = row[3];
let bleed = row[2];
PageBoxes {
page: (i + 1) as u32,
media_mm: wh_mm(row[0]),
crop_mm: wh_mm(row[1]),
trim_mm: wh_mm(trim),
bleed_mm: wh_mm(bleed),
trim_set: page.dict.contains(b"TrimBox"),
bleed_set: page.dict.contains(b"BleedBox"),
bleed_margin_mm: [mm(trim[0] - bleed[0]), mm(trim[1] - bleed[1]), mm(bleed[2] - trim[2]), mm(bleed[3] - trim[3])],
}
})
.collect()
}
/// The images drawn on each page, and how many placements had no measurable resolution.
fn image_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> (Vec<DrawnImage>, usize) {
let mut out = Vec::new();
let mut unknown = 0usize;
let mut seen = std::collections::HashSet::new();
for (i, page) in pages.iter().enumerate() {
let page_no = (i + 1) as u32;
if let Ok(placed) = pdfcraft_edit::page_images(doc, i) {
for image in placed {
if let Some(ppi) = placed_ppi(&image) {
if let Some(object) = image.object {
seen.insert((page_no, object));
}
if !ppi.is_finite() {
unknown = unknown.saturating_add(1);
continue;
}
let w = (image.rect[2] - image.rect[0]).abs();
let h = (image.rect[3] - image.rect[1]).abs();
out.push(DrawnImage { page: page_no, name: image.name, ppi, pixels: [image.width, image.height], size_mm: [mm(w), mm(h)] });
}
}
}
// Images drawn inside a form XObject are not listed above. Their resolution is the
// smallest use on this page, which is what the optimizer already measures.
for (object, ppi) in pdfcraft_optimize::effective_resolutions(doc, &[page.obj]) {
if seen.contains(&(page_no, object)) {
continue;
}
if !ppi.is_finite() {
unknown = unknown.saturating_add(1);
continue;
}
out.push(DrawnImage { page: page_no, name: format!("object {object}"), ppi, pixels: [0, 0], size_mm: [0.0, 0.0] });
}
}
out.sort_by(|a, b| a.page.cmp(&b.page).then(a.name.cmp(&b.name)).then(a.ppi.total_cmp(&b.ppi)));
(out, unknown)
}
/// The effective resolution of a raster placement, `None` for Form artwork. Not finite (NaN)
/// when the placement can't be measured: callers report that rather than pass it.
fn placed_ppi(image: &PageImage) -> Option<f64> {
if image.is_form {
return None;
}
Some(matrix_ppi(image.matrix, image.width, image.height))
}
/// Pixels per inch of a `width` × `height` image drawn through `matrix`, or NaN when the matrix
/// is degenerate or not finite or the image has no pixels.
fn matrix_ppi(matrix: [f64; 6], width: u32, height: u32) -> f64 {
let [a, b, c, d, ..] = matrix;
let (sx, sy) = (a.hypot(b), c.hypot(d));
if width == 0 || height == 0 || !sx.is_finite() || !sy.is_finite() || sx <= 1e-6 || sy <= 1e-6 {
return f64::NAN;
}
let ppi = (f64::from(width) / (sx / 72.0)).min(f64::from(height) / (sy / 72.0));
if ppi.is_finite() { ppi } else { f64::NAN }
}
fn is_screen_profile(intent: &str) -> bool {
let id = intent.to_ascii_lowercase();
id.contains("srgb") || id.contains("adobe rgb") || id.contains("display")
}
fn is_press_profile(intent: &str) -> bool {
let id = intent.to_ascii_lowercase();
id.contains("cmyk") || id.contains("fogra") || id.contains("coated") || id.contains("swop") || id.contains("gracol")
}
fn mm(points: f64) -> f64 {
points * 25.4 / 72.0
}
fn wh_mm(rect: [f64; 4]) -> [f64; 2] {
[mm((rect[2] - rect[0]).abs()), mm((rect[3] - rect[1]).abs())]
}
fn size_mm(wh: [f64; 2]) -> String {
format!("{:.1} × {:.1}", wh[0], wh[1])
}
fn min_mm(sides: [f64; 4]) -> String {
let n = sides.into_iter().fold(f64::INFINITY, f64::min);
if n.is_finite() { format!("{n:.1}") } else { "0.0".into() }
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::*;
fn image(doc: &mut Document, w: u32, h: u32) -> pdfcraft_cos::ObjRef {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w as i64));
d.set(b"Height".to_vec(), Object::Int(h as i64));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
doc.add(Object::Stream(Stream::flate(d, &vec![0; (w as usize) * (h as usize)])))
}
fn page(doc: &mut Document, name: &str, object: pdfcraft_cos::ObjRef, content: &str) -> pdfcraft_cos::ObjRef {
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
let mut x = Dict::new();
x.set(name.as_bytes().to_vec(), Object::Ref(object));
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(x));
let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec())));
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
p.set(b"Resources".to_vec(), Object::Dict(res));
p.set(b"Contents".to_vec(), Object::Ref(contents));
let r = doc.add(Object::Dict(p));
doc.update_dict(pages, |d| {
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
kids.push(Object::Ref(r));
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
d.set(b"Kids".to_vec(), Object::Array(kids));
})
.unwrap();
r
}
#[test]
fn a_drawn_image_is_measured_where_it_is_placed() {
let mut doc = Document::new_empty();
// 100 px across 144 pt (2 in) is 50 ppi, not "100 px on a letter page".
let soft = image(&mut doc, 100, 100);
// 600 px across 72 pt (1 in) is 600 ppi.
let sharp = image(&mut doc, 600, 600);
page(&mut doc, "Soft", soft, "q 144 0 0 144 36 600 cm /Soft Do Q");
let sharp_page = page(&mut doc, "Sharp", sharp, "q 72 0 0 72 36 36 cm /Sharp Do Q");
doc.update_dict(sharp_page, |d| {
let inset = |n: i64| Object::Array(vec![n.into(), n.into(), (612 - n).into(), (792 - n).into()]);
d.set(b"TrimBox".to_vec(), inset(18));
d.set(b"BleedBox".to_vec(), inset(0));
})
.unwrap();
let report = inspect(&doc);
let soft = report.images.iter().find(|image| image.name == "Soft").unwrap();
let sharp = report.images.iter().find(|image| image.name == "Sharp").unwrap();
assert!((soft.ppi - 50.0).abs() < 0.5, "{}", soft.ppi);
assert!((sharp.ppi - 600.0).abs() < 0.5, "{}", sharp.ppi);
assert!(report.findings.iter().any(|f| f.id == "image-ppi" && f.severity == "fail" && f.page == Some(1)));
assert!(!report.findings.iter().any(|f| f.id == "image-ppi" && f.page == Some(2)));
// Page 1 has no TrimBox: one document-level note, not a bleed warning per page.
assert!(!report.findings.iter().any(|f| f.id == "page-bleed"), "{:?}", report.findings);
let trim: Vec<_> = report.findings.iter().filter(|f| f.id == "page-trim").collect();
assert_eq!(trim.len(), 1, "{:?}", report.findings);
assert_eq!((trim[0].severity, trim[0].page), ("info", None));
assert!(trim[0].message.starts_with("1 of 2 pages"), "{}", trim[0].message);
assert_eq!(report.resolution_unknown, 0);
assert!(report.text().contains("50 ppi"));
assert!(report.text().contains("verification only"));
}
#[test]
fn untrimmed_pages_get_one_note_and_a_tight_trim_gets_a_warning() {
let mut doc = Document::new_empty();
let img = image(&mut doc, 600, 600);
for _ in 0..3 {
page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q");
}
let report = inspect(&doc);
let notes: Vec<_> = report.findings.iter().filter(|f| f.id.starts_with("page-")).collect();
assert_eq!(notes.len(), 1, "{notes:?}");
assert_eq!(notes[0].message, "No page has a TrimBox, so bleed can't be measured.");
// A TrimBox 1 pt inside the crop, with no BleedBox: 0.35 mm of bleed is a real warning.
let tight = page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q");
doc.update_dict(tight, |d| d.set(b"TrimBox".to_vec(), Object::Array(vec![1.into(), 1.into(), 611.into(), 791.into()]))).unwrap();
let report = inspect(&doc);
assert!(report.findings.iter().any(|f| f.id == "page-bleed" && f.severity == "warn" && f.page == Some(4)));
assert_eq!(report.findings.iter().filter(|f| f.id == "page-bleed").count(), 1);
}
#[test]
fn an_unmeasurable_placement_is_never_passed() {
assert!(matrix_ppi([f64::NAN, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan());
assert!(matrix_ppi([f64::INFINITY, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan());
assert!(matrix_ppi([0.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan());
assert!(matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 0, 600).is_nan());
assert!((matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600) - 600.0).abs() < 1e-9);
// A placement scaled past f64's range and then multiplied by zero has a NaN matrix. The
// content readers drop it before it is measured; it must never be reported as passing.
let mut doc = Document::new_empty();
let img = image(&mut doc, 100, 100);
let huge = format!("1{}", "0".repeat(400));
page(&mut doc, "Im", img, &format!("q {huge} 0 0 {huge} 0 0 cm 0 1 1 0 0 0 cm /Im Do Q"));
let report = inspect(&doc);
assert!(report.images.iter().all(|image| image.ppi.is_finite()), "{:?}", report.images);
assert!(!report.findings.iter().any(|f| f.id == "image-ppi"), "{:?}", report.findings);
}
}

View File

@ -1,84 +0,0 @@
//! Edit text on a scanned page: an OCR word is invisible text over the scan, so rewriting it must
//! also hide the scanned word. The paper colour around the word becomes the cover drawn over it
//! ([`pdfcraft_edit::BlockStyle::cover`]) and the colour of its ink becomes the new text's.
use std::sync::Arc;
use pdfcraft_edit::TextBlock;
use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, Tile};
/// Device pixels per point the word's surroundings are read at.
const SCALE: f32 = 3.0;
/// The most pixels read for one paragraph; a larger area is read at a lower resolution.
const MAX_PIXELS: f32 = 4_000_000.0;
/// How far around the word (points) the paper is read.
const RING: f32 = 4.0;
/// The paper and ink colours (RGB 0–1) of invisible paragraph `b` on `page`, or `None` when the
/// paragraph is visible text or the page can't be read.
pub(crate) fn sample(cos: &pdfcraft_cos::Document, info: &PageInfo, page: usize, b: &TextBlock) -> Option<([f64; 3], [f64; 3])> {
if !b.invisible {
return None;
}
// The word's box in view space (points, y down).
let corners = [[b.rect[0], b.rect[1]], [b.rect[2], b.rect[1]], [b.rect[0], b.rect[3]], [b.rect[2], b.rect[3]]]
.map(|[x, y]| info.user_to_view(x as f32, y as f32));
let (x0, x1) = corners.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, z), c| (a.min(c[0]), z.max(c[0])));
let (y0, y1) = corners.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, z), c| (a.min(c[1]), z.max(c[1])));
let word = [x0, y0, x1, y1];
let area = [x0 - RING, y0 - RING, x1 + RING, y1 + RING];
if word.iter().any(|v| !v.is_finite()) || x1 <= x0 || y1 <= y0 {
return None;
}
let pixels = (area[2] - area[0]) * (area[3] - area[1]) * SCALE * SCALE;
let scale = if pixels > MAX_PIXELS { SCALE * (MAX_PIXELS / pixels).sqrt() } else { SCALE };
let (page_w, page_h) = (info.width * scale, info.height * scale);
let clamp = |v: f32, max: f32| v.clamp(0.0, max.max(0.0)).floor() as u32;
let (tx0, ty0) = (clamp(area[0] * scale, page_w), clamp(area[1] * scale, page_h));
let (tx1, ty1) = (clamp(area[2] * scale, page_w), clamp(area[3] * scale, page_h));
if tx1 <= tx0 || ty1 <= ty0 {
return None;
}
let tile = Tile { x: tx0, y: ty0, w: tx1 - tx0, h: ty1 - ty0 };
// The working file as it is: written out only if the document holds unsaved changes, which
// it doesn't between edits (each one reopens the file it wrote).
let bytes = if cos.is_modified() {
Arc::new(pdfcraft_cos::write_incremental(cos, &pdfcraft_cos::SaveOptions::default()).ok()?)
} else {
cos.bytes().clone()
};
// The document's true colours, whatever the reading colours are set to.
let shot = PageRenderer::new(bytes, RenderConfig::default()).render(RenderRequest { page, scale, tile: Some(tile), ..Default::default() });
if shot.error.is_some() {
return None;
}
let pad = ((y1 - y0) * 0.1).clamp(0.5, 2.0);
let (mut paper, mut ink) = (Vec::new(), Vec::new());
for (i, px) in shot.rgba.as_chunks::<4>().0.iter().enumerate() {
let (col, row) = (i % shot.width.max(1) as usize, i / shot.width.max(1) as usize);
let x = (tx0 as f32 + col as f32 + 0.5) / scale;
let y = (ty0 as f32 + row as f32 + 0.5) / scale;
let rgb = [px[0], px[1], px[2]].map(|c| f64::from(c) / 255.0);
if x >= x0 && x <= x1 && y >= y0 && y <= y1 {
ink.push(rgb);
} else if x < x0 - pad || x > x1 + pad || y < y0 - pad || y > y1 + pad {
paper.push(rgb);
}
}
let luma = |c: &[f64; 3]| 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
let by_luma = |a: &[f64; 3], b: &[f64; 3]| luma(a).total_cmp(&luma(b));
paper.sort_by(by_luma);
ink.sort_by(by_luma);
// The paper is the ring's median, so a table rule or a neighbouring letter in it doesn't count.
let paper = paper.get(paper.len() / 2).copied().unwrap_or([1.0; 3]);
// The ink is the average of the word's darkest pixels; a box without any is printed in black.
let darkest = ink.get(..ink.len().div_ceil(8)).unwrap_or_default();
let ink = if darkest.is_empty() {
[0.0; 3]
} else {
let sum = darkest.iter().fold([0.0; 3], |s, c| [s[0] + c[0], s[1] + c[1], s[2] + c[2]]);
sum.map(|v| v / darkest.len() as f64)
};
let ink = if luma(&paper) - luma(&ink) < 0.25 { [0.0; 3] } else { ink };
Some((paper, ink))
}

View File

@ -118,43 +118,6 @@ pub struct SignatureImage {
size: [usize; 2],
}
/// Decode an imported PNG or JPEG within the source limits (file size, 32 megapixels, 8192 pixels
/// per side, bounded allocation), upright per its EXIF orientation and scaled down to at most
/// 4 megapixels and 4096 pixels per side. Shared by image signatures and scanned photos.
pub(crate) fn decode_source(bytes: &[u8]) -> Result<DynamicImage, SignatureImageError> {
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
return Err(SignatureImageError::Size);
}
let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(SignatureImageError::Format);
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_SOURCE_SIDE);
limits.max_image_height = Some(MAX_SOURCE_SIDE);
// 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size.
limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4);
reader.limits(limits);
let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() };
let mut decoder = reader.into_decoder().map_err(too_big)?;
let (w, h) = decoder.dimensions();
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
return Err(SignatureImageError::Size);
}
let orientation = decoder.orientation()?;
let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?;
image.apply_orientation(orientation);
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt());
if scale < 1.0 {
let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1));
// `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps.
image = image.thumbnail(nw, nh);
}
Ok(image)
}
impl SignatureImage {
/// Read at most the file limit plus one byte, even if the file grows while being read.
pub fn read(reader: impl Read) -> Result<Self, SignatureImageError> {
@ -164,7 +127,36 @@ impl SignatureImage {
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, SignatureImageError> {
let image = decode_source(bytes)?;
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
return Err(SignatureImageError::Size);
}
let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(SignatureImageError::Format);
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_SOURCE_SIDE);
limits.max_image_height = Some(MAX_SOURCE_SIDE);
// 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size.
limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4);
reader.limits(limits);
let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() };
let mut decoder = reader.into_decoder().map_err(too_big)?;
let (w, h) = decoder.dimensions();
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
return Err(SignatureImageError::Size);
}
let orientation = decoder.orientation()?;
let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?;
image.apply_orientation(orientation);
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt());
if scale < 1.0 {
let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1));
// `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps.
image = image.thumbnail(nw, nh);
}
let rgba = image.into_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
let mut png = Cursor::new(Vec::new());

View File

@ -1,904 +0,0 @@
//! Signatures scanned from a photo of paper (Fill & Sign ▸ Sign ▸ Scan).
//!
//! People sign a sheet of paper and photograph it, usually with a phone. Such a photo is far from
//! a clean signature image: it shows the table around the sheet, shadows of the phone or hand,
//! uneven light and sensor noise, and it is often larger than an image signature may be. This
//! module turns the framed part of the photo into a [`SignatureImage`]: the ink in its own colour
//! on a transparent background, trimmed, so it is placed, saved and reused like any image
//! signature.
//!
//! Everything is local and bounded: the photo is decoded within the same limits as an imported
//! image signature (a 4 MiB file, 32 megapixels, 8192 pixels per side), downscaled to at most
//! 4 megapixels and [`WORKING_MAX_SIDE`] pixels per side, and only that copy is kept. So one
//! scan of the whole frame touches at most 4 megapixels.
//!
//! The ink is separated from the paper in four steps:
//! 1. **Paper estimate.** A grayscale morphological closing (a maximum filter, then a minimum
//! filter, van Herk/Gil-Werman, O(1) per pixel) removes every dark detail narrower than the
//! window, i.e. the pen strokes, while following lighting gradients and sharp shadow edges.
//! Bright noise peaks are clipped first, and edge pixels are repeated outside the image, so a
//! table cut off by the frame keeps its size and stays background.
//! 2. **Darkness** of each pixel relative to that paper, slightly smoothed so that glossy
//! ballpoint strokes with a lighter centre come out solid. The border of large dark areas
//! that are not paper (the table) is cleared; shadows on the paper are kept.
//! 3. **Hysteresis.** Pixels darker than the threshold are ink; fainter pixels are kept only when
//! they belong to a blob with real ink, so faint stroke ends survive and stray marks don't.
//! Blobs with too little ink are dropped as dust.
//! 4. **Output.** The ink is trimmed with a small margin, drawn in its average colour with the
//! coverage as alpha, and normalized through [`SignatureImage`].
use std::io::{Cursor, Read};
use std::sync::Arc;
use image::{DynamicImage, ImageFormat, RgbImage};
use crate::signature_image::{SignatureImage, SignatureImageError};
/// Largest photo file accepted: the image signature limit (decoding shares its limits too).
pub const MAX_PHOTO_BYTES: usize = crate::signature_image::MAX_SIGNATURE_IMAGE_BYTES;
/// Longest side of the copy that is kept and scanned (it also holds at most 4 megapixels).
pub const WORKING_MAX_SIDE: u32 = 4096;
/// Longest side of the framed area that is cleaned for the final signature.
pub const SCAN_MAX_SIDE: u32 = 2400;
/// Longest side used for a quick preview, e.g. while the frame is being dragged.
pub const PREVIEW_MAX_SIDE: u32 = 900;
/// Longest side of the downscaled photo used to find the signature.
const DETECTION_MAX_SIDE: u32 = 1024;
/// Largest signature produced, in pixels (the image signature limit).
const MAX_OUTPUT_PIXELS: u64 = 4 << 20;
/// The default of the Ink sensitivity slider (0–100).
pub const DEFAULT_SENSITIVITY: u8 = 50;
/// Detection uses a higher sensitivity so that faint strokes are framed too.
const DETECTION_SENSITIVITY: u8 = 70;
/// Darkness threshold (0–255, relative to the paper) at sensitivity 0 and 100.
const MAX_THRESHOLD: f32 = 100.0;
const MIN_THRESHOLD: f32 = 12.0;
/// Fainter pixels down to this fraction of the threshold are kept when they touch ink.
const HYSTERESIS_RATIO: f32 = 0.5;
/// Paper estimate window, relative to the photo's longest side; wider than half a felt-tip stroke.
const BACKGROUND_RADIUS_RATIO: f32 = 0.012;
const MIN_BACKGROUND_RADIUS: usize = 4;
/// One pixel of smoothing per this many pixels of the longest side.
const SMOOTHING_SCALE: usize = 1000;
/// A background darker than this fraction of the nearby paper is not paper (e.g. the table).
const DARK_AREA_RATIO: f32 = 0.45;
/// Blobs with fewer ink pixels than this fraction of the area (at least the minimum) are dust.
const SPECKLE_AREA_RATIO: f64 = 0.000_02;
const MIN_SPECKLE_AREA: usize = 6;
/// Blobs with less ink than this fraction of the largest one are not part of the signature.
const DETECTION_MIN_INK_RATIO: f64 = 0.1;
/// A detected signature this much taller than wide was photographed sideways.
const UPRIGHT_RATIO: u32 = 2;
/// Transparent margin around the trimmed signature, in pixels.
const TRIM_PADDING: usize = 8;
#[derive(Debug, thiserror::Error)]
pub enum ScanError {
#[error("Choose a PNG or JPEG photo.")]
Format,
#[error("Choose a photo smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")]
Size,
#[error("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.")]
NoInk,
#[error("Couldn't read the photo: {0}")]
Read(#[from] std::io::Error),
#[error("Couldn't decode the photo: {0}")]
Decode(#[from] image::ImageError),
#[error(transparent)]
Signature(#[from] SignatureImageError),
}
/// A rectangle in photo pixels, top-left origin.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PhotoRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl PhotoRect {
/// Keep the rectangle inside a `width`×`height` photo, at least one pixel large.
pub fn clamped(self, width: u32, height: u32) -> Self {
let w = self.width.clamp(1, width.max(1));
let h = self.height.clamp(1, height.max(1));
Self { x: self.x.min(width.saturating_sub(w)), y: self.y.min(height.saturating_sub(h)), width: w, height: h }
}
/// The same area after the photo (`width`×`height` before turning) is turned a quarter.
pub fn turned(self, turn: Turn, width: u32, height: u32) -> Self {
match turn {
Turn::Clockwise => {
Self { x: height.saturating_sub(self.y.saturating_add(self.height)), y: self.x, width: self.height, height: self.width }
}
Turn::CounterClockwise => {
Self { x: self.y, y: width.saturating_sub(self.x.saturating_add(self.width)), width: self.height, height: self.width }
}
}
}
}
/// A quarter turn of the photo.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Turn {
Clockwise,
CounterClockwise,
}
/// A decoded, upright and downscaled photo of a signature on paper.
#[derive(Clone, Debug, PartialEq)]
pub struct SignaturePhoto {
rgb: Arc<RgbImage>,
}
impl SignaturePhoto {
/// Read at most the file limit plus one byte, even if the file grows while being read.
pub fn read(reader: impl Read) -> Result<Self, ScanError> {
let mut bytes = Vec::new();
reader.take(MAX_PHOTO_BYTES as u64 + 1).read_to_end(&mut bytes)?;
Self::from_bytes(&bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ScanError> {
if bytes.len() > MAX_PHOTO_BYTES {
return Err(ScanError::Size);
}
// The image signature decoder: same limits, EXIF orientation (phones store portrait
// photos as landscape pixels plus an orientation), scaled down to 4 megapixels.
let image = crate::signature_image::decode_source(bytes).map_err(|e| match e {
SignatureImageError::Size => ScanError::Size,
SignatureImageError::Format => ScanError::Format,
e => ScanError::Signature(e),
})?;
Ok(Self { rgb: Arc::new(image.into_rgb8()) })
}
/// Width and height in pixels.
pub fn size(&self) -> [u32; 2] {
[self.rgb.width(), self.rgb.height()]
}
/// The photo as RGB pixels, for showing it.
pub fn rgb(&self) -> &RgbImage {
&self.rgb
}
/// The photo downscaled so that its longest side is at most `max_side`, as RGBA for display.
pub fn thumbnail(&self, max_side: u32) -> image::RgbaImage {
let [w, h] = self.size();
let scale = (f64::from(max_side.max(1)) / f64::from(w.max(h).max(1))).min(1.0);
let nw = ((f64::from(w) * scale).round() as u32).max(1);
let nh = ((f64::from(h) * scale).round() as u32).max(1);
DynamicImage::ImageRgb8(image::imageops::resize(&*self.rgb, nw, nh, image::imageops::FilterType::Triangle)).into_rgba8()
}
/// Turn the photo a quarter, returning `rect` moved along with it.
pub fn turn(&mut self, turn: Turn, rect: PhotoRect) -> PhotoRect {
let [w, h] = self.size();
self.rgb = Arc::new(match turn {
Turn::Clockwise => image::imageops::rotate90(&*self.rgb),
Turn::CounterClockwise => image::imageops::rotate270(&*self.rgb),
});
rect.turned(turn, w, h)
}
/// Find the signature, or `None` when the photo shows no clear ink.
pub fn detect(&self) -> Option<PhotoRect> {
let [w, h] = self.size();
let small = scaled(&self.rgb, PhotoRect { x: 0, y: 0, width: w, height: h }, DETECTION_MAX_SIDE);
let (sw, sh) = (small.width(), small.height());
let region = detect_region(&luminance(&small), sw as usize, sh as usize)?;
let ratio = f64::from(w) / f64::from(sw.max(1));
let scale = |v: usize| ((v as f64) * ratio).round().clamp(0.0, f64::from(u32::MAX)) as u32;
Some(PhotoRect { x: scale(region.x), y: scale(region.y), width: scale(region.width), height: scale(region.height) }.clamped(w, h))
}
/// Frame the signature for the user: turn a sideways signature upright, then return its
/// frame, or a centred default frame when no signature was found.
pub fn auto_frame(&mut self) -> PhotoRect {
if let Some(rect) = self.detect() {
if rect.height > rect.width.saturating_mul(UPRIGHT_RATIO) {
// Signatures are written horizontally. The direction is a guess; the user can
// turn the photo further.
return self.turn(Turn::CounterClockwise, rect);
}
return rect;
}
let [w, h] = self.size();
PhotoRect { x: w / 7, y: h * 7 / 20, width: w * 5 / 7, height: h * 3 / 10 }.clamped(w, h)
}
/// Clean the framed area into a transparent signature. `max_side` bounds the processed area:
/// [`SCAN_MAX_SIDE`] for the result, [`PREVIEW_MAX_SIDE`] for a quick preview.
pub fn scan(&self, rect: PhotoRect, sensitivity: u8, max_side: u32) -> Result<SignatureImage, ScanError> {
let [w, h] = self.size();
let rect = rect.clamped(w, h);
let area = scaled(&self.rgb, rect, max_side.clamp(16, WORKING_MAX_SIDE));
let (aw, ah) = (area.width() as usize, area.height() as usize);
// Size the paper estimate on the whole photo, not on the frame, so that thick strokes in
// a tight frame are not taken for paper.
let scale = aw as f32 / rect.width.max(1) as f32;
let radius = (w.max(h) as f32 * BACKGROUND_RADIUS_RATIO * scale).round() as usize;
let ink = extract_ink(&luminance(&area), aw, ah, Options { sensitivity, despeckle: true, background_radius: Some(radius) });
let bounds = ink.bounds.ok_or(ScanError::NoInk)?;
let png = render(&area, &ink.alpha, bounds)?;
Ok(SignatureImage::from_bytes(&png)?)
}
}
/// The framed area of `rgb`, downscaled so that its longest side is at most `max_side`.
fn scaled(rgb: &RgbImage, rect: PhotoRect, max_side: u32) -> RgbImage {
let rect = rect.clamped(rgb.width(), rgb.height());
let view = image::imageops::crop_imm(rgb, rect.x, rect.y, rect.width, rect.height).to_image();
let longest = rect.width.max(rect.height);
if longest <= max_side {
return view;
}
let scale = f64::from(max_side) / f64::from(longest);
let nw = ((f64::from(rect.width) * scale).round() as u32).max(1);
let nh = ((f64::from(rect.height) * scale).round() as u32).max(1);
image::imageops::resize(&view, nw, nh, image::imageops::FilterType::Triangle)
}
/// Perceived luminance (ITU BT.601).
fn luminance(rgb: &RgbImage) -> Vec<u8> {
rgb.pixels().map(|p| (0.299 * f32::from(p[0]) + 0.587 * f32::from(p[1]) + 0.114 * f32::from(p[2])).round().clamp(0.0, 255.0) as u8).collect()
}
/// A rectangle in the pixels of a processed buffer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Region {
x: usize,
y: usize,
width: usize,
height: usize,
}
#[derive(Clone, Copy, Debug)]
struct Options {
sensitivity: u8,
despeckle: bool,
/// Paper estimate window; by default a fraction of the buffer's longest side.
background_radius: Option<usize>,
}
/// Ink coverage (0 = paper, 255 = solid ink) and the bounding box of the solid ink.
struct Ink {
alpha: Vec<u8>,
bounds: Option<Region>,
}
/// The darkness threshold for a sensitivity of 0–100; higher sensitivity keeps fainter strokes.
fn threshold(sensitivity: u8) -> f32 {
let s = f32::from(sensitivity.min(100)) / 100.0;
MAX_THRESHOLD - s * (MAX_THRESHOLD - MIN_THRESHOLD)
}
/// In-place separable box blur.
fn box_blur(values: &mut [f32], width: usize, height: usize, radius: usize) {
if radius == 0 || width == 0 || height == 0 || values.len() != width * height {
return;
}
let mut line = vec![0.0_f32; width.max(height)];
let mut blur = |get: &dyn Fn(usize) -> f32, len: usize, out: &mut dyn FnMut(usize, f32)| {
for (i, slot) in line.iter_mut().take(len).enumerate() {
*slot = get(i);
}
let (mut sum, mut count) = (0.0_f32, 0_u32);
for v in line.iter().take(radius.min(len)) {
sum += v;
count += 1;
}
for i in 0..len {
if let Some(v) = line.get(i + radius).filter(|_| i + radius < len) {
sum += v;
count += 1;
}
if let Some(v) = i.checked_sub(radius + 1).and_then(|j| line.get(j)) {
sum -= v;
count = count.saturating_sub(1);
}
out(i, if count == 0 { 0.0 } else { sum / count as f32 });
}
};
let mut row = vec![0.0_f32; width];
let mut tmp = vec![0.0_f32; width];
for y in 0..height {
let start = y * width;
let Some(src) = values.get(start..start + width) else { return };
row.copy_from_slice(src);
blur(&|x| row.get(x).copied().unwrap_or(0.0), width, &mut |x, v| {
if let Some(slot) = tmp.get_mut(x) {
*slot = v;
}
});
if let Some(dst) = values.get_mut(start..start + width) {
dst.copy_from_slice(&tmp);
}
}
let mut column = vec![0.0_f32; height];
let mut tmp = vec![0.0_f32; height];
for x in 0..width {
for (y, slot) in column.iter_mut().enumerate() {
*slot = values.get(y * width + x).copied().unwrap_or(0.0);
}
blur(&|y| column.get(y).copied().unwrap_or(0.0), height, &mut |y, v| {
if let Some(slot) = tmp.get_mut(y) {
*slot = v;
}
});
for (y, v) in tmp.iter().enumerate() {
if let Some(slot) = values.get_mut(y * width + x) {
*slot = *v;
}
}
}
}
/// Sliding-window maximum or minimum over `radius` pixels on each side (van Herk/Gil-Werman).
/// Edge values are repeated outside the line.
fn sliding_extremum(src: &[u8], dst: &mut [u8], radius: usize, max: bool) {
let len = src.len().min(dst.len());
if len == 0 {
return;
}
let pick = |a: u8, b: u8| if max { a.max(b) } else { a.min(b) };
let size = 2 * radius + 1;
let total = len + 2 * radius;
let at = |i: usize| src.get(i.saturating_sub(radius).min(len - 1)).copied().unwrap_or(0);
let mut g = vec![0_u8; total];
let mut h = vec![0_u8; total];
let mut prev = 0;
for (i, slot) in g.iter_mut().enumerate() {
let v = at(i);
prev = if i % size == 0 { v } else { pick(prev, v) };
*slot = prev;
}
let mut next = 0;
for (i, slot) in h.iter_mut().enumerate().rev() {
let v = at(i);
next = if i == total - 1 || (i + 1) % size == 0 { v } else { pick(next, v) };
*slot = next;
}
for (i, out) in dst.iter_mut().take(len).enumerate() {
if let (Some(&a), Some(&b)) = (h.get(i), g.get(i + 2 * radius)) {
*out = pick(a, b);
}
}
}
/// Separable square maximum (`max`) or minimum filter.
fn square_filter(src: &[u8], width: usize, height: usize, radius: usize, max: bool) -> Vec<u8> {
let mut rows = vec![0_u8; src.len()];
for (s, d) in src.chunks_exact(width.max(1)).zip(rows.chunks_exact_mut(width.max(1))) {
sliding_extremum(s, d, radius, max);
}
let mut out = vec![0_u8; src.len()];
let mut column = vec![0_u8; height];
let mut filtered = vec![0_u8; height];
for x in 0..width {
for (y, slot) in column.iter_mut().enumerate() {
*slot = rows.get(y * width + x).copied().unwrap_or(0);
}
sliding_extremum(&column, &mut filtered, radius, max);
for (y, v) in filtered.iter().enumerate() {
if let Some(slot) = out.get_mut(y * width + x) {
*slot = *v;
}
}
}
out
}
/// The brightness of the paper behind every pixel (see the module documentation).
fn estimate_background(gray: &[u8], width: usize, height: usize, radius: usize) -> Vec<u8> {
let r = radius.max(MIN_BACKGROUND_RADIUS);
// Clip bright noise peaks, which the maximum filter would otherwise pick, so that the faint
// halo around every stroke doesn't count as ink. Only clipping keeps shadow edges sharp.
let mut blurred: Vec<f32> = gray.iter().map(|&v| f32::from(v)).collect();
box_blur(&mut blurred, width, height, (r / 6).max(1));
let clipped: Vec<u8> = gray.iter().zip(&blurred).map(|(&g, &b)| f32::from(g).min(b).round().clamp(0.0, 255.0) as u8).collect();
let closed = square_filter(&square_filter(&clipped, width, height, r, true), width, height, r, false);
let mut smooth: Vec<f32> = closed.iter().map(|&v| f32::from(v)).collect();
box_blur(&mut smooth, width, height, (r / 4).max(1));
// The blur must never make the paper darker than the photo itself.
smooth.iter().zip(gray).map(|(&s, &g)| s.max(f32::from(g)).round().clamp(1.0, 255.0) as u8).collect()
}
/// Label 8-connected blobs of non-zero `mask` pixels; returns the labels (0 = none) and count.
fn label_components(mask: &[bool], width: usize, height: usize) -> (Vec<u32>, usize) {
let mut labels = vec![0_u32; mask.len()];
let mut stack: Vec<usize> = Vec::new();
let mut count = 0_usize;
for start in 0..mask.len() {
if !mask.get(start).copied().unwrap_or(false) || labels.get(start).copied().unwrap_or(1) != 0 {
continue;
}
count += 1;
let label = u32::try_from(count).unwrap_or(u32::MAX);
if let Some(l) = labels.get_mut(start) {
*l = label;
}
stack.push(start);
while let Some(index) = stack.pop() {
let (x, y) = (index % width, index / width);
for ny in y.saturating_sub(1)..=(y + 1).min(height.saturating_sub(1)) {
for nx in x.saturating_sub(1)..=(x + 1).min(width.saturating_sub(1)) {
let n = ny * width + nx;
if mask.get(n).copied().unwrap_or(false)
&& let Some(l) = labels.get_mut(n)
&& *l == 0
{
*l = label;
stack.push(n);
}
}
}
}
}
(labels, count)
}
/// Separate the ink from the paper (see the module documentation).
fn extract_ink(gray: &[u8], width: usize, height: usize, options: Options) -> Ink {
let n = width * height;
if n == 0 || gray.len() != n {
return Ink { alpha: vec![0; gray.len()], bounds: None };
}
let radius =
options.background_radius.unwrap_or_else(|| (width.max(height) as f32 * BACKGROUND_RADIUS_RATIO).round() as usize).max(MIN_BACKGROUND_RADIUS);
let background = estimate_background(gray, width, height, radius);
let mut darkness: Vec<f32> =
gray.iter().zip(&background).map(|(&g, &bg)| if g >= bg { 0.0 } else { 255.0 * f32::from(bg - g) / f32::from(bg.max(1)) }).collect();
let smoothing = width.max(height) / SMOOTHING_SCALE;
box_blur(&mut darkness, width, height, smoothing);
// Where the sheet meets a much darker area (the table) is not ink; a shadow on the paper is
// only moderately darker and is kept.
let edge = smoothing + 2;
let brightest = square_filter(&background, width, height, edge, true);
let darkest = square_filter(&background, width, height, edge, false);
for ((d, &lo), &hi) in darkness.iter_mut().zip(&darkest).zip(&brightest) {
if f32::from(lo) < f32::from(hi) * DARK_AREA_RATIO {
*d = 0.0;
}
}
let strong = threshold(options.sensitivity);
let weak = strong * HYSTERESIS_RATIO;
let candidates: Vec<bool> = darkness.iter().map(|&d| d >= weak).collect();
let (labels, count) = label_components(&candidates, width, height);
let mut strong_pixels = vec![0_usize; count + 1];
for (&label, &d) in labels.iter().zip(&darkness) {
if label != 0
&& d >= strong
&& let Some(c) = strong_pixels.get_mut(label as usize)
{
*c += 1;
}
}
let min_strong = if options.despeckle { MIN_SPECKLE_AREA.max((n as f64 * SPECKLE_AREA_RATIO).round() as usize) } else { 1 };
let mut alpha = vec![0_u8; n];
let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0, 0);
for (i, ((a, &label), &d)) in alpha.iter_mut().zip(&labels).zip(&darkness).enumerate() {
if label == 0 || strong_pixels.get(label as usize).copied().unwrap_or(0) < min_strong {
continue;
}
// Smooth ramp from the weak to the strong threshold.
let c = ((d - weak) / (strong - weak).max(f32::EPSILON)).clamp(0.0, 1.0);
*a = (255.0 * c * c * (3.0 - 2.0 * c)).round() as u8;
if *a >= 128 {
let (x, y) = (i % width, i / width);
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
let bounds = (min_x <= max_x && min_y <= max_y).then(|| Region { x: min_x, y: min_y, width: max_x - min_x + 1, height: max_y - min_y + 1 });
Ink { alpha, bounds }
}
/// Where the signature is in a (downscaled) photo of the whole sheet: nearby strokes are merged,
/// blobs touching the photo border (paper edges, table, fingers) are ignored, and the blob with
/// the most ink is framed together with the other words on its line.
fn detect_region(gray: &[u8], width: usize, height: usize) -> Option<Region> {
let ink = extract_ink(gray, width, height, Options { sensitivity: DETECTION_SENSITIVITY, despeckle: true, background_radius: None });
ink.bounds?;
let solid: Vec<u8> = ink.alpha.iter().map(|&a| if a >= 128 { 255 } else { 0 }).collect();
let radius = ((width.max(height) as f64) * 0.03).round().max(2.0) as usize;
let merged: Vec<bool> = square_filter(&solid, width, height, radius, true).iter().map(|&v| v != 0).collect();
let (labels, count) = label_components(&merged, width, height);
#[derive(Clone, Copy)]
struct Blob {
ink: usize,
min_x: usize,
min_y: usize,
max_x: usize,
max_y: usize,
border: bool,
}
let mut blobs = vec![Blob { ink: 0, min_x: usize::MAX, min_y: usize::MAX, max_x: 0, max_y: 0, border: false }; count + 1];
for (i, (&label, &s)) in labels.iter().zip(&solid).enumerate() {
let Some(blob) = blobs.get_mut(label as usize).filter(|_| label != 0) else { continue };
let (x, y) = (i % width, i / width);
if x == 0 || y == 0 || x + 1 == width || y + 1 == height {
blob.border = true;
}
if s != 0 {
blob.ink += 1;
blob.min_x = blob.min_x.min(x);
blob.min_y = blob.min_y.min(y);
blob.max_x = blob.max_x.max(x);
blob.max_y = blob.max_y.max(y);
}
}
let candidates = || blobs.iter().skip(1).filter(|b| !b.border && b.ink > 0);
let best = *candidates().max_by_key(|b| b.ink)?;
// Signatures often have several words: add every blob with a fair amount of ink on the same line.
let span = best.max_y - best.min_y;
let (top, bottom) = (best.min_y.saturating_sub(span), best.max_y + span);
let min_ink = best.ink as f64 * DETECTION_MIN_INK_RATIO;
let mut frame = best;
for b in candidates().filter(|b| b.ink as f64 >= min_ink && b.max_y >= top && b.min_y <= bottom) {
frame.min_x = frame.min_x.min(b.min_x);
frame.min_y = frame.min_y.min(b.min_y);
frame.max_x = frame.max_x.max(b.max_x);
frame.max_y = frame.max_y.max(b.max_y);
}
let pad = radius;
let (x, y) = (frame.min_x.saturating_sub(pad), frame.min_y.saturating_sub(pad));
Some(Region { x, y, width: (frame.max_x + 1 + pad).min(width) - x, height: (frame.max_y + 1 + pad).min(height) - y })
}
/// The trimmed ink in its average colour with the coverage as alpha, as PNG.
fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result<Vec<u8>, ScanError> {
let (w, h) = (area.width() as usize, area.height() as usize);
let x0 = bounds.x.saturating_sub(TRIM_PADDING);
let y0 = bounds.y.saturating_sub(TRIM_PADDING);
let x1 = (bounds.x + bounds.width + TRIM_PADDING).min(w);
let y1 = (bounds.y + bounds.height + TRIM_PADDING).min(h);
let (ow, oh) = (x1.saturating_sub(x0), y1.saturating_sub(y0));
if ow == 0 || oh == 0 {
return Err(ScanError::NoInk);
}
// One ink colour: the average of the solid strokes, so blue ink stays blue and paper tint at
// the stroke edges doesn't show.
let (mut sum, mut solid) = ([0_u64; 3], 0_u64);
for (p, &a) in area.pixels().zip(alpha) {
if a == 255 {
for (s, &c) in sum.iter_mut().zip(p.0.iter()) {
*s += u64::from(c);
}
solid += 1;
}
}
let colour = if solid == 0 { [0, 0, 0] } else { sum.map(|s| (s / solid).min(255) as u8) };
let mut out = image::RgbaImage::new(u32::try_from(ow).unwrap_or(1), u32::try_from(oh).unwrap_or(1));
for (y, row) in out.rows_mut().enumerate() {
for (x, px) in row.enumerate() {
let a = alpha.get((y0 + y) * w + x0 + x).copied().unwrap_or(0);
*px = image::Rgba([colour[0], colour[1], colour[2], a]);
}
}
let mut out = DynamicImage::ImageRgba8(out);
let pixels = (ow as u64).saturating_mul(oh as u64);
if pixels > MAX_OUTPUT_PIXELS || ow > WORKING_MAX_SIDE as usize || oh > WORKING_MAX_SIDE as usize {
let scale = (MAX_OUTPUT_PIXELS as f64 / pixels as f64).sqrt().min(f64::from(WORKING_MAX_SIDE) / ow.max(oh) as f64);
let nw = ((ow as f64 * scale).floor() as u32).max(1);
let nh = ((oh as f64 * scale).floor() as u32).max(1);
out = out.resize_exact(nw, nh, image::imageops::FilterType::Triangle);
}
let mut png = Cursor::new(Vec::new());
out.write_to(&mut png, ImageFormat::Png)?;
// A PNG larger than a saved signature may be is scaled down by `SignatureImage::from_bytes`.
Ok(png.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
/// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`.
fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec<u8> {
let mut out = vec![0; width * height];
for y in 0..height {
for x in 0..width {
let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]);
out[y * width + x] = if inside { ink_value } else { paper(x, y) };
}
}
out
}
fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage {
RgbImage::from_fn(width as u32, height as u32, |x, y| {
let v = gray[y as usize * width + x as usize];
image::Rgb([v, v, v])
})
}
fn jpeg(image: &RgbImage) -> Vec<u8> {
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Jpeg).unwrap();
out.into_inner()
}
/// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and
/// 8192 pixels per side decoded, and a working copy of at most 4 megapixels.
#[test]
fn photos_keep_the_image_signature_limits() {
let png = |image: &RgbImage| {
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Png).unwrap();
out.into_inner()
};
assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size)));
assert!(SignaturePhoto::read(std::io::repeat(0)).is_err());
assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size)));
assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format)));
let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size();
assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}");
assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}");
}
fn region(r: [usize; 4]) -> Region {
Region { x: r[0], y: r[1], width: r[2], height: r[3] }
}
fn opts() -> Options {
Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None }
}
#[test]
fn box_blur_averages_and_keeps_a_constant_signal() {
let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0];
box_blur(&mut values, 5, 1, 1);
assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]);
let mut flat = vec![7.0; 12];
box_blur(&mut flat, 4, 3, 2);
assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4));
}
#[test]
fn square_filters_match_a_naive_window_with_repeated_edges() {
let (w, h) = (13, 7);
let src: Vec<u8> = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect();
for radius in [1, 2, 5] {
for max in [true, false] {
let fast = square_filter(&src, w, h, radius, max);
for y in 0..h {
for x in 0..w {
let mut want = if max { 0 } else { 255 };
for dy in -(radius as i64)..=radius as i64 {
for dx in -(radius as i64)..=radius as i64 {
let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize;
let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize;
let v = src[sy * w + sx];
want = if max { want.max(v) } else { want.min(v) };
}
}
assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}");
}
}
}
}
}
#[test]
fn components_are_eight_connected() {
let mask = [true, false, false, false, false, true, false, true, false, false, false, false];
let (labels, count) = label_components(&mask, 4, 3);
assert_eq!(count, 2);
assert_eq!(labels[0], labels[5]);
assert_ne!(labels[7], labels[0]);
assert_eq!(labels[1], 0);
}
#[test]
fn sensitivity_lowers_the_threshold() {
assert!(threshold(0) > threshold(50));
assert!(threshold(50) > threshold(100));
assert_eq!(threshold(200), threshold(100));
}
#[test]
fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() {
let (w, h) = (160, 80);
let stroke = [20, 30, 120, 6];
let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30);
let ink = extract_ink(&gray, w, h, opts());
assert_eq!(ink.alpha[10 * w + 150], 0);
assert_eq!(ink.alpha[33 * w + 30], 255);
assert_eq!(ink.alpha[33 * w + 130], 255);
assert_eq!(ink.bounds, Some(region(stroke)));
}
#[test]
fn a_sharp_shadow_edge_is_not_ink() {
let (w, h) = (200, 120);
let stroke = [30, 52, 140, 4];
let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30);
let ink = extract_ink(&gray, w, h, opts());
for y in 58..63 {
assert_eq!(ink.alpha[y * w + 10], 0);
assert_eq!(ink.alpha[y * w + 190], 0);
}
assert_eq!(ink.bounds, Some(region(stroke)));
}
#[test]
fn a_table_at_the_edge_of_the_frame_is_not_ink() {
let (w, h) = (300, 120);
let stroke = [60, 60, 200, 4];
let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30);
let ink = extract_ink(&gray, w, h, opts());
assert_eq!(ink.alpha[5 * w + 150], 0);
assert_eq!(ink.alpha[60 * w + 5], 0);
assert_eq!(ink.alpha[11 * w + 150], 0);
assert_eq!(ink.bounds, Some(region(stroke)));
}
#[test]
fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() {
let (w, h) = (600, 200);
let stroke = [50, 90, 500, 18];
let gray = photo(w, h, |_, _| 225, &[stroke], 30);
let middle = 99 * w + 300;
// Sized on the tight frame alone, the window is narrower than the stroke.
assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0);
let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() });
assert_eq!(ink.bounds, Some(region(stroke)));
assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255));
}
#[test]
fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() {
let (w, h) = (200, 60);
let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40);
for y in 28..32 {
for x in (100..180).chain(10..15) {
gray[y * w + x] = 175;
}
}
let ink = extract_ink(&gray, w, h, opts());
assert!(ink.alpha[30 * w + 150] > 0);
assert_eq!(ink.alpha[30 * w + 12], 0);
assert_eq!(ink.bounds, Some(region([20, 28, 160, 4])));
}
#[test]
fn dust_is_removed_unless_despeckling_is_off() {
let (w, h) = (200, 100);
let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20);
assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0);
let ink = extract_ink(&gray, w, h, opts());
assert_eq!(ink.alpha[5 * w + 190], 0);
assert_eq!(ink.bounds, Some(region([20, 40, 150, 5])));
}
#[test]
fn faint_strokes_need_a_higher_sensitivity() {
let (w, h) = (120, 60);
let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165);
assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none());
assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some());
}
#[test]
fn blank_paper_has_no_ink() {
let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0);
assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none());
assert!(detect_region(&gray, 64, 64).is_none());
}
#[test]
fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() {
let (w, h) = (400, 200);
let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30);
let r = detect_region(&gray, w, h).unwrap();
assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}");
assert!(r.y > 20 && r.y + r.height < 185, "{r:?}");
}
#[test]
fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() {
let (w, h) = (400, 300);
let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 };
let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30);
let r = detect_region(&gray, w, h).unwrap();
assert!(r.x > 80 && r.x + r.width < 320, "{r:?}");
assert!(r.y > 70 && r.y + r.height < 230, "{r:?}");
}
#[test]
fn rects_turn_with_the_photo_and_back() {
let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 };
let turned = rect.turned(Turn::Clockwise, 100, 80);
assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 });
assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect);
assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 });
assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0);
}
#[test]
fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() {
let (w, h) = (600, 400);
let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h);
for (x, y, p) in image.enumerate_pixels_mut() {
if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) {
*p = image::Rgb([30, 50, 140]);
}
}
let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap();
let rect = photo.auto_frame();
assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}");
let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap();
let [sw, sh] = signature.size();
assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}");
let pixels = signature.rgba().as_chunks::<4>().0;
assert_eq!(pixels[0][3], 0, "the margin is transparent");
let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect();
assert!(!solid.is_empty());
assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]);
let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap();
assert!(quick.size()[0] <= sw);
}
#[test]
fn a_sideways_signature_is_turned_upright() {
let (w, h) = (400, 600);
let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30);
let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap();
let rect = photo.auto_frame();
assert_eq!(photo.size(), [600, 400]);
assert!(rect.width > rect.height * 2, "{rect:?}");
}
#[test]
fn an_empty_frame_reports_that_no_signature_was_found() {
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap();
let mut copy = photo.clone();
assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 });
assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk)));
}
#[test]
fn large_photos_are_downscaled_and_invalid_ones_rejected() {
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap();
let [w, h] = photo.size();
assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}");
assert!(photo.thumbnail(512).width() <= 512);
assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format)));
assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format)));
assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err());
assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size)));
assert!(SignaturePhoto::read(std::io::repeat(0)).is_err());
// A header claiming a huge image is rejected before decoding.
let mut png = Vec::new();
png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR");
png.extend_from_slice(&20_000_u32.to_be_bytes());
png.extend_from_slice(&20_000_u32.to_be_bytes());
png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]);
assert!(SignaturePhoto::from_bytes(&png).is_err());
}
#[test]
fn degenerate_buffers_do_not_panic() {
assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none());
assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none());
assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none());
assert!(detect_region(&[255; 4], 2, 2).is_none());
let mut empty: Vec<f32> = Vec::new();
box_blur(&mut empty, 0, 0, 3);
assert!(square_filter(&[], 0, 0, 2, true).is_empty());
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap();
let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0);
}
}

View File

@ -36,20 +36,6 @@ fn session_with(n: usize) -> (Session, DocId) {
(s, id)
}
#[test]
fn prepared_open_does_not_mutate_the_session_until_commit() {
fn assert_send<T: Send>() {}
assert_send::<crate::PreparedDocument>();
let mut session = Session::new();
let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares");
assert!(session.get(DocId(1)).is_none());
let id = session.commit_open(prepared);
assert_eq!(id, DocId(1));
assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3);
}
#[test]
fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
@ -114,191 +100,6 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
}
}
/// A blank page turned `degrees`, with one annotation placed from displayed coordinates.
fn rotated_blank(degrees: i64) -> (Session, DocId) {
let mut session = Session::new().with_clock(|| 1_700_000_000);
let blank = session.create_blank(200.0, 300.0, 1).unwrap();
let id = session.open_new("drawing.pdf", blank).unwrap();
session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap();
(session, id)
}
/// The user-space box of a `w` by `h` point rectangle centred at a displayed point.
fn displayed_rect(info: &pdfcraft_render::PageInfo, vx: f64, vy: f64, w: f64, h: f64) -> [f64; 4] {
let corners = [(vx - w / 2.0, vy - h / 2.0), (vx + w / 2.0, vy + h / 2.0)];
let mapped: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.view_to_user(*x as f32, *y as f32)).collect();
[
f64::from(mapped[0][0].min(mapped[1][0])),
f64::from(mapped[0][1].min(mapped[1][1])),
f64::from(mapped[0][0].max(mapped[1][0])),
f64::from(mapped[0][1].max(mapped[1][1])),
]
}
fn render_page(session: &Session, id: DocId) -> pdfcraft_render::RenderedPage {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
rendered
}
fn stamp_pixel(rendered: &pdfcraft_render::RenderedPage, x: u32, y: u32) -> [u8; 4] {
let offset = ((y * rendered.width + x) * 4) as usize;
rendered.rgba[offset..offset + 4].try_into().unwrap()
}
/// #1398: a Sign Here tag points left as displayed, on every page rotation, including after save.
#[test]
fn rubber_stamps_point_left_as_displayed_on_rotated_pages() {
let (w, h) = StampKind::SignHere.size();
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = displayed_rect(&info, 100.0, 40.0, w, h);
let shape = Shape::Stamp { rect, stamp: StampKind::SignHere, by: None };
session
.apply(
id,
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape,
contents: String::new(),
author: "Test".into(),
}),
)
.unwrap();
let check = |session: &Session, id: DocId| {
let rendered = render_page(session, id);
let doc = session.get(id).unwrap();
let info = doc.info.pages[0].clone();
let stamp = doc.info.annotations[0].rect;
let a = info.user_to_view(stamp[0], stamp[1]);
let b = info.user_to_view(stamp[2], stamp[3]);
let (left, top) = (a[0].min(b[0]), a[1].min(b[1]));
let (right, bottom) = (a[0].max(b[0]), a[1].max(b[1]));
let scale = 2.0;
let at = |x: f32, y: f32| stamp_pixel(&rendered, (x * scale) as u32, (y * scale) as u32);
// The tag's point is the left edge. The flat end fills the right edge.
let tip = at(left + 4.0, (top + bottom) / 2.0);
let outside = at(left + 4.0, top + 4.0);
let end = at(right - 4.0, top + 4.0);
assert!(tip[0] > 180 && tip[2] < 80, "rotation {degrees}: tip {tip:?} should be the orange point");
assert!(outside.iter().all(|c| *c > 240), "rotation {degrees}: above the point {outside:?} should be paper");
assert!(end[0] > 180 && end[2] < 80, "rotation {degrees}: flat end {end:?} should be orange");
};
check(&session, id);
let saved = session.save_bytes(id).unwrap();
let mut reopened = Session::new().with_clock(|| 1_700_000_000);
let again = reopened.open("drawing.pdf", None, saved, None).unwrap();
check(&reopened, again);
}
}
/// A one-page PDF whose left half is red and whose right half is white, as displayed.
fn half_red_page() -> Vec<u8> {
let content = "1 0 0 rg 0 0 40 40 re f";
let objs = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 80 40] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /MediaBox [0 0 80 40] >>".to_vec(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(obj);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset: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
}
fn quadrant_png() -> Arc<Vec<u8>> {
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
Arc::new(png.into_inner())
}
fn assert_upright_quadrants(session: &Session, id: DocId, degrees: i64) {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip(colours) {
assert_eq!(stamp_pixel(&rendered, x, y), colour, "rotation {degrees}; displayed point ({x}, {y})");
}
}
/// #1398: an explicit rectangle keeps the caller's box and turns the picture upright.
#[test]
fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() {
let bytes = quadrant_png();
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0);
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect,
name: "Original quadrants".into(),
file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
assert_upright_quadrants(&session, id, degrees);
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
assert_upright_quadrants(&session, id, degrees);
}
}
/// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box.
#[test]
fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() {
let bytes = Arc::new(half_red_page());
for (natural, degrees) in [true, false].into_iter().flat_map(|natural| [0, 90, 180, 270].map(|degrees| (natural, degrees))) {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let at = info.view_to_user(100.0, 100.0).map(f64::from);
let rect = if natural { [at[0], at[1], at[0], at[1]] } else { displayed_rect(&info, 100.0, 100.0, 80.0, 40.0) };
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect,
name: "Half red".into(),
file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
let left = stamp_pixel(&rendered, 80, 100);
let right = stamp_pixel(&rendered, 120, 100);
assert!(left[0] > 200 && left[1] < 40, "rotation {degrees}, natural {natural}: left {left:?} should be red");
assert!(right.iter().all(|c| *c > 240), "rotation {degrees}, natural {natural}: right {right:?} should be paper");
}
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
@ -629,134 +430,15 @@ fn combine_extract_split_and_insert_from_file() {
let parts = s.split(id, &pdfcraft_organize::SplitBy::PageCount(2)).unwrap();
assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::<Vec<_>>(), [(1, 2), (3, 3)]);
// Insert pages from a file, undoably, into the open document.
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: other,
pages: Some(vec![1]),
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]);
assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf");
assert_eq!(page_texts(&s, id).len(), 3);
// Garbage sources fail cleanly.
let bad = s.apply(
id,
Edit::InsertPagesFrom {
name: "junk.pdf".into(),
bytes: Arc::new(b"nope".to_vec()),
pages: None,
at: 0,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
);
let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 });
assert!(matches!(bad, Err(EditError::Source(_))));
}
/// A one-page PDF whose form holds one text field named `name` (a widget that is its own field).
fn form_with_field(name: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 4
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
bytes
}
/// Inserting pages of a file whose form names a field as the open document's: the incoming
/// Duplicating a page copies the document's own field: the copy keeps its name (Duplicate is
/// not Combine), and nothing about renaming lands in the repair log.
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(!doc.repair_log().iter().any(|l| l.contains("Text1-1")), "{:?}", doc.repair_log());
}
/// field is renamed to keep the two separate, the repair log says so (warning that scripts
/// were not rewritten), the undo step's label counts the renames, and undo puts the names back.
#[test]
fn insert_from_file_renames_colliding_fields_and_notes_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: Arc::new(form_with_field("Text1")),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
assert!(
doc.repair_log().iter().any(|l| l.contains("Text1 → Text1-1") && l.contains("were not rewritten")),
"the note names the rename and the scripts caveat: {:?}",
doc.repair_log()
);
let label = s.undo(id).unwrap();
assert_eq!(label.as_str(), "Insert pages from b.pdf — 1 field renamed");
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1"], "undo restores the single original field");
}
/// A combine reports the fields it renamed — the tool result tells callers what their scripts
/// must call the field now — and honours a caller's choice to merge instead.
#[test]
fn combine_reports_renamed_fields_and_can_merge() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> =
vec![("a.pdf".into(), Arc::new(form_with_field("Text1")), None), ("b.pdf".into(), Arc::new(form_with_field("Text1")), None)];
let out = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
let cid = s.open_new("Combined.pdf", out.bytes).unwrap();
let doc = s.get(cid).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
let merged = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Merge).unwrap();
assert!(merged.renamed_fields.is_empty(), "merge renames nothing");
}
/// The Combine tab's pre-flight over byte sources: what combining them, in order, would rename
/// in each. A file that cannot be opened previews as nothing (its row already shows the problem).
#[test]
fn combine_field_conflicts_previews_over_bytes() {
let s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> = vec![
("a.pdf".into(), Arc::new(form_with_field("Text1")), None),
("b.pdf".into(), Arc::new(form_with_field("Text1")), None),
("junk.pdf".into(), Arc::new(b"nope".to_vec()), None),
];
let preview = s.combine_field_conflicts(&sources, &[]);
assert_eq!(preview, vec![vec![], vec![("Text1".to_string(), "Text1-1".to_string())], vec![]]);
}
/// A minimal baseline JPEG (headers only; the data is embedded as is).
fn jpeg_bytes() -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
@ -1012,13 +694,12 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() {
assert!(d.allows_printing() && d.allows_modification() && d.allows_assembly(), "only copying is withheld");
assert_eq!(d.check_copying(), Err(EditError::NotPermitted("copying text and images")));
assert!(d.page_image_file(0, 0).unwrap_err().contains("don't allow copying"));
let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.pptx", "export.html", "export.rtf", "export.all_images"];
let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"];
let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id));
for c in extracting {
assert!(!enabled(&s, id, c), "{c} is disabled");
}
assert!(enabled(&s, id, "edit.find") && enabled(&s, id, "a11y.check"), "searching and accessibility don't copy");
assert!(s.get(id).unwrap().autotag_preview(0).is_ok(), "like a11y.check, the auto-tag preview is allowed");
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(s.get(oid).unwrap().allows_copying());
assert!(extracting.iter().all(|c| enabled(&s, oid, c)));
@ -1707,72 +1388,6 @@ fn preparing_a_form_adds_renames_and_deletes_fields() {
assert_eq!(names(&s), ["Text1", "Check Box1", "email"]);
}
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
/// the page's /Resources holds.
fn one_line_page(resources: &str, body: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
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
}
/// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does.
fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
let text = r.render(RenderRequestFor::text(0)).text.expect("the page has text");
let range = text.find(needle).pop().expect("the page shows the text");
let info = &doc.info.pages[0];
let quads = text.line_rects(range).into_iter().map(|v| pdfcraft_annot::rect_quad(info.view_rect_to_user(v).map(f64::from))).collect();
let shape = Shape::Redact { quads, overlay: String::new(), look: Default::default() };
let mark =
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() });
s.apply(id, mark).unwrap();
}
/// Redaction removes text by the positions its own interpreter works out. Here the font and size
/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does
/// not model, so it places no glyph under the mark the user drew over the word as displayed: it
/// used to report success and leave the word in the file. The renderer is asked now, and the edit
/// is refused.
#[test]
fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() {
let line = "Public words then SECRET42 and more";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"));
let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
let refused = s.apply(id, Edit::ApplyRedactions { pages: None });
assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}");
// Nothing was changed: the mark is still pending and the page still shows the line.
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
assert_eq!(page_texts(&s, id), [line]);
// The same line with its font set by `Tf`: the mark is applied and the word is gone.
let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET"));
let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
let shown = page_texts(&s, id).swap_remove(0);
assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}");
}
#[test]
fn redaction_marks_apply_for_good_and_undo() {
let (mut s, id) = session_with(2);
@ -1957,17 +1572,7 @@ fn create_and_reduce() {
// A file with the same page imported twice shares nothing until reduced.
let (mut s2, id2) = session_with(1);
let src = Arc::new(fixture(1));
s2.apply(
id2,
Edit::InsertPagesFrom {
name: "x".into(),
bytes: src.clone(),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
s2.apply(id2, Edit::InsertPagesFrom { name: "x".into(), bytes: src.clone(), pages: None, at: 1 }).unwrap();
let full = s2.save_full_bytes(id2).unwrap();
let (reduced, _) = s2.reduced_bytes(id2).unwrap();
assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len());
@ -2313,12 +1918,6 @@ fn links_from_urls_and_link_edits() {
assert_eq!(d.links.len(), 1);
assert_eq!(d.info.links.len(), 1, "the viewer follows it");
assert!(s.find_urls(id).is_empty(), "already linked");
// Remove all links and undo.
s.apply(id, Edit::RemoveLinks { pages: None }).unwrap();
assert!(s.get(id).unwrap().links.is_empty());
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove all links"));
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().links.len(), 1);
// Link Properties and delete.
let l = s.get(id).unwrap().links[0].clone();
s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap();
@ -2328,42 +1927,6 @@ fn links_from_urls_and_link_edits() {
assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove");
}
#[test]
fn appearance_only_edit_preserves_gotor_action_semantics() {
let objects = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_vec(),
b"<< /Type /Annot /Subtype /Link /Rect [10 10 80 30] /A << /S /GoToR /F (target.pdf) /D [2 /FitH 180] /NewWindow true >> >>".to_vec(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
let mut session = Session::new();
let id = session.open("source.pdf", Some("/docs/source.pdf".into()), Arc::new(bytes), None).unwrap();
let before = session.get(id).unwrap().info.links[0].target.clone();
let mut style = session.get(id).unwrap().links[0].style;
style.visible = true;
style.width = 2.0;
session.apply(id, Edit::SetLink { page: 0, index: 0, rect: None, action: None, style: Some(style) }).unwrap();
let after = &session.get(id).unwrap().info.links[0].target;
assert_eq!(*after, before, "the GoToR file, destination and /NewWindow hint must remain unchanged");
assert_eq!(session.get(id).unwrap().links[0].style, style, "the appearance edit is applied");
}
#[test]
fn comments_lock_take_checkmarks_hide_and_summarize() {
let (mut s, id) = session_with(2);
@ -2416,21 +1979,6 @@ fn comment_summary_includes_replies_to_replies() {
assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}");
}
#[test]
fn reading_colors_set_and_invalidated() {
let (mut s, id) = session_with(1);
// Every open document holds a value; the default is Off.
assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK));
assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK));
assert!(!s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK), "unchanged");
assert!(s.set_reading_colors(id, ReadingColors::default()), "back to Off");
assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
// No such document.
assert_eq!(s.reading_colors(DocId(u64::MAX)), None);
assert!(!s.set_reading_colors(DocId(u64::MAX), ReadingColors::WHITE_ON_BLACK));
}
#[test]
fn signing_saving_trusting_and_commenting_afterwards() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
@ -2872,8 +2420,6 @@ fn exporting_office_files_keeps_images() {
assert_eq!(d.export_pages()[0].images.len(), 1);
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(16).any(|w| w == b"word/media/image"));
let pptx = d.export_office(compare::OfficeFormat::Pptx);
assert!(pptx.windows(15).any(|w| w == b"ppt/media/image"));
if let Ok(dir) = std::env::var("PDFCRAFT_EXPORT_DIR") {
std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap();
}
@ -2937,33 +2483,10 @@ BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body s
let d = s.get(id).unwrap();
let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap();
assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}");
let pptx = d.export_office(compare::OfficeFormat::Pptx);
assert!(pptx.starts_with(b"PK"));
assert!(pptx.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml"));
let blocks = &d.export_pages()[0].blocks;
assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}");
}
#[test]
fn office_export_positions_follow_the_displayed_crop_rotation_and_units() {
use pdfcraft_cos::{Document as CosDocument, ObjRef, Object, SaveOptions, write_full};
let mut pdf = CosDocument::open(Arc::new(fixture(1))).unwrap();
let mut page = pdf.get(ObjRef::new(4, 0)).as_dict().unwrap().clone();
page.set("CropBox", Object::Array([10, 20, 190, 280].map(Object::Int).to_vec()));
page.set("Rotate", Object::Int(90));
page.set("UserUnit", Object::Int(2));
pdf.set(ObjRef::new(4, 0), page);
let mut session = Session::new();
let id = session.open("turned.pdf", None, Arc::new(write_full(&pdf, &SaveOptions::default()).unwrap()), None).unwrap();
let doc = session.get(id).unwrap();
let pages = doc.export_pages();
let b = &pages[0].blocks[0];
assert!((b.rotation + 90.0).abs() < 0.01, "clockwise page rotation: {b:?}");
assert!((b.rect[0] - 248.0).abs() < 0.01, "crop and UserUnit applied: {b:?}");
assert_eq!(b.size, 48.0);
assert_eq!((pages[0].width, pages[0].height), (520.0, 360.0));
}
#[test]
fn guard_turns_a_panic_into_an_error() {
assert_eq!(guard(|| 7), Ok(7));
@ -3348,63 +2871,6 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
assert!(String::from_utf8_lossy(&out).contains("<item>only</item>"));
}
#[test]
fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
let (sections, rows) = (10, 20);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("many.pdf", None, bytes, None).expect("opens");
let elapsed = started.elapsed();
let out = s.take_js_output(id);
assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors);
let form = &s.get(id).unwrap().form;
let totals: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("total")).collect();
assert_eq!(totals.len(), sections * rows);
assert!(totals.iter().all(|f| !f.value.is_empty()), "every row's total was calculated");
let sums: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("sum")).collect();
assert_eq!(sums.len(), sections);
// 2 × (1 + 2 + … + rows)
let expect = (rows * (rows + 1)).to_string();
assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::<Vec<_>>());
eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections);
// A change recalculates them all again, well within the event budget.
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors);
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]);
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, vec![(rows * (rows + 1) - 2 + 200).to_string()]);
eprintln!("recalculated after a change in {:?}", started.elapsed());
}
#[test]
fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
// In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate
// reads `a`. A failed script's half-done changes reach nobody. And `d` sets a choice list
// by its shown text: the next script reads the saved value the engine kept.
let tpl = pdfcraft_xfa::fixtures::fields_template("<form><page1><country>JP</country></page1></form>").replace(
r#"<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
r#"<field name="a" w="1in" h="0.3in"><ui><numericEdit/></ui><value><integer>1</integer></value></field>
<field name="b" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue = 99; throw new Error("boom");</script></calculate></field>
<field name="c" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue + 1</script></calculate></field>
<field name="d" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script contentType="application/x-javascript">country.rawValue = "Canada"; "set"</script></calculate></field>
<field name="e" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script>country</script></calculate></field>
<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("iso.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}");
assert_eq!(value(&s, "a"), vec!["1".to_string()], "the failed script's write reached nothing");
assert_eq!(value(&s, "c"), vec!["2".to_string()]);
assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value");
assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value");
assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]);
}
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
@ -3420,7 +2886,6 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
assert_eq!(value(&s, "qty"), vec!["2".to_string()]);
assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id));
assert_eq!(value(&s, "grand"), vec!["0".to_string()]);
assert!(!s.get(id).unwrap().xfa_scripts_off());
assert!(!s.get(id).unwrap().dirty);
assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden");
// A change recalculates.
@ -3511,68 +2976,6 @@ fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_
assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into()));
}
#[test]
fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() {
// `carried` inside the area and a second `carried` outside it bind to one data node (areas
// are not data scopes). In document order: `setter` writes the inner one, `echo` reads the
// outer one in the same event, and the saved data holds the one value.
let tpl = pdfcraft_xfa::fixtures::leave_template("<form><page><carried>5</carried></page></form>")
.replace(
r#" <field name="rest" y="2.5in" w="2in" h="0.3in" access="readOnly">"#,
r#" <field name="setter" y="3in" w="2in" h="0.3in"><ui><numericEdit/></ui><calculate><script>carried = 9
9</script></calculate></field>
<field name="rest" y="2.5in" w="2in" h="0.3in" access="readOnly">"#,
)
.replace(
" </subform>\n </subform>\n</subform>",
r#" </subform>
<field name="carried" y="8.2in" w="2in" h="0.3in"><ui><numericEdit/></ui></field>
<field name="echo" y="8.5in" w="2in" h="0.3in"><ui><numericEdit/></ui><calculate><script>carried + 0</script></calculate></field>
</subform>
</subform>"#,
);
assert!(tpl.contains("name=\"echo\"") && tpl.contains("name=\"setter\""), "the fixture was reshaped");
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("shared.pdf", None, bytes, None).expect("opens");
let out = s.take_js_output(id);
assert!(out.errors.is_empty(), "{out:?}");
let values = |s: &Session, id: DocId, n: &str| {
s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with(n)).map(|f| f.value.join("")).collect::<Vec<_>>()
};
assert_eq!(values(&s, id, "carried"), vec!["9".to_string(), "9".to_string()], "both fields show the node's value");
assert_eq!(values(&s, id, "echo"), vec!["9".to_string()], "read in the same event the setter ran in");
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9"));
assert_eq!(d.count(&pdfcraft_xfa::som_to_path("form[0].page[0]"), "carried"), 1, "one data node");
// The inner field hidden (no widget): the outer widget still takes the node's value, and
// an unrelated edit does not write the old value back over it.
let hidden = Arc::new(pdfcraft_xfa::fixtures::shell(
&tpl.replace(r#"<field name="carried" y="1.5in""#, r#"<field name="carried" presence="hidden" y="1.5in""#),
));
let id = s.open("hidden.pdf", None, hidden, None).expect("opens");
assert_eq!(values(&s, id, "carried"), vec!["9".to_string()]);
assert_eq!(values(&s, id, "echo"), vec!["9".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "allowance".into(), value: FieldValue::Text("31".into()) }).unwrap();
assert_eq!(values(&s, id, "carried"), vec!["9".to_string()]);
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
assert_eq!(pdfcraft_xfa::data_of(&cos).unwrap().text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9"));
// A check box bound to the node a text field sets: checked by its own on state.
let checkbox = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(
r#"<field name="carried" y="1.5in" w="2in" h="0.3in"><ui><numericEdit/></ui></field>"#,
r#"<field name="carried" y="1.5in" w="2in" h="0.3in"><ui><checkButton/></ui><items><integer>9</integer><integer>0</integer></items></field>"#,
)));
let id = s.open("check.pdf", None, checkbox, None).expect("opens");
let carried: Vec<(pdfcraft_forms::FieldKind, Vec<String>)> =
s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("carried")).map(|f| (f.kind, f.value.clone())).collect();
assert_eq!(
carried,
vec![(pdfcraft_forms::FieldKind::CheckBox, vec!["9".to_string()]), (pdfcraft_forms::FieldKind::Text, vec!["9".to_string()])],
"{carried:?}"
);
}
#[test]
fn dynamic_xfa_forms_run_formcalc_scripts() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
@ -3668,7 +3071,6 @@ fn xfa_scripts_stay_off_with_javascript_off() {
let qty = s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone();
assert!(qty.is_empty(), "no initialize script ran");
assert!(s.run_javascript(id, "", Some("addRow")).is_err());
assert!(!s.get(id).unwrap().xfa_scripts_off(), "the preference, not a script that ran away");
}
#[test]
@ -3790,13 +3192,11 @@ fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
assert!(out.errors.iter().any(|e| e.contains("total") && e.contains("maximum number of iteration")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("abandoned")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("scripts are off")), "{:?}", out.errors);
assert!(s.get(id).unwrap().xfa_scripts_off());
// The scripts stay off: a change runs nothing more.
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(s.take_js_output(id).errors.is_empty());
assert!(s.get(id).unwrap().xfa_scripts_off());
}
/// The page view's raster of `page` at 1 px/pt (the document's own render pool).
@ -4103,61 +3503,6 @@ fn recovered_user_passwords_reach_the_renderer_byte_for_byte() {
assert_eq!(crate::renderer_password(&[0xC7, b'o', b'k']), "Çok");
}
/// AES-256 passwords as typed, each with its SASLprep form, which ISO 32000-2 keys the file on:
/// a decomposed é (NFKC composes it), a no-break space (mapped to a space) and fullwidth letters
/// (what a Japanese IME types in full-width mode; NFKC narrows them). Precomposed "café" is the
/// control: SASLprep leaves it as it is.
const SASLPREP_CHANGES: [(&str, &str); 4] =
[("cafe\u{301}", "caf\u{E9}"), ("a\u{A0}b", "a b"), ("\u{FF41}\u{FF42}", "ab"), ("caf\u{E9}", "caf\u{E9}")];
#[test]
fn aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed() {
for (typed, prepared) in SASLPREP_CHANGES {
let (mut s, id) = session_with(1);
// The renderer used to take the password as typed, so the protected file didn't reopen
// and the edit was rolled back: "the password is incorrect".
s.apply(id, Edit::Protect(protection(Some(typed), None))).unwrap_or_else(|e| panic!("{typed:?}: {e:?}"));
assert!(s.get(id).unwrap().info.encrypted, "{typed:?}");
assert_eq!(page_texts(&s, id), ["Page 1"], "{typed:?}");
let saved = s.save_bytes(id).unwrap();
for pw in [typed, prepared] {
let mut again = Session::new();
let reopened = again.open("x.pdf", None, saved.clone(), Some(pw)).unwrap_or_else(|e| panic!("{typed:?} as {pw:?}: {e:?}"));
assert_eq!(page_texts(&again, reopened), ["Page 1"], "{typed:?} as {pw:?}");
}
assert!(matches!(Session::new().open("x.pdf", None, saved, Some("cafe")), Err(OpenError::WrongPassword)), "{typed:?}");
}
}
#[test]
fn aes256_files_whose_passwords_saslprep_changes_open_as_typed() {
// A fullwidth O: SASLprep makes the owner password "Owner".
let (owner, owner_prepared) = ("\u{FF2F}wner", "Owner");
for (typed, prepared) in SASLPREP_CHANGES {
// pdfcraft-cos keys the file on the SASLprep forms, as ISO 32000-2 asks.
let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
let params = pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: typed,
owner_password: owner,
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
};
cos.set_encryption(&params).unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
let mut s = Session::new();
// pdfcraft-cos accepts the password as typed (it applies SASLprep); the renderer used to
// refuse it, so the session called it a wrong password.
for pw in [typed, prepared, owner, owner_prepared] {
let id = s.open("x.pdf", None, bytes.clone(), Some(pw)).unwrap_or_else(|e| panic!("{typed:?} as {pw:?}: {e:?}"));
assert_eq!(page_texts(&s, id), ["Page 1"], "{typed:?} as {pw:?}");
assert!(s.get(id).unwrap().info.encrypted, "{typed:?} as {pw:?}");
}
assert!(matches!(s.open("x.pdf", None, bytes, Some("cafe")), Err(OpenError::WrongPassword)), "{typed:?}");
}
}
/// A synthetic phone photo, 400 × 300 px: a white sheet, tilted, on a grey table, with a black
/// mark in the sheet's top-left corner. Returns the PNG and the sheet's corners in pixels.
fn camera_photo() -> (Vec<u8>, [[f64; 2]; 4]) {
@ -4382,144 +3727,3 @@ fn fonts_added_by_a_content_edit_show_after_refresh_fonts() {
session.refresh_fonts(id);
assert_eq!(format!("{:?}", session.get(id).unwrap().info.fonts), fonts);
}
/// Gap 13: every edit writes a new working file (the original plus one revision). Before the
/// next one is written, the current one is let go of by the working bytes, the display bytes and
/// the renderer: on a 1 GiB file, keeping it is a whole extra gigabyte at the peak of each edit.
#[test]
fn the_working_file_is_let_go_of_before_the_next_is_written() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
let doc = s.doc_mut(id).unwrap();
let (working, original) = (Arc::downgrade(&doc.bytes), doc.editor.as_ref().unwrap().cos.bytes().clone());
assert!(!Arc::ptr_eq(&doc.bytes, &original), "an edited working file is a buffer of its own");
Session::release_working(doc);
assert!(Arc::ptr_eq(&doc.bytes, &original) && Arc::ptr_eq(&doc.display, &original));
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while working.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "the working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
// The refresh that follows brings everything back.
Session::refresh(doc).unwrap();
assert_eq!(s.get(id).unwrap().info.pages[1].rotation, 90);
let _ = shown(&s, id, 1);
}
/// Every refresh path (full, comments, content, undo, redo) lets go of the previous working file
/// and still shows the current state.
#[test]
fn edits_let_go_of_the_previous_working_file() {
let (mut s, id) = session_with(2);
let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
let comment = || {
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape: shape.clone(),
contents: String::new(),
author: "Test".into(),
})
};
let watermark =
|| Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false };
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
// A full refresh, a comment refresh, a content refresh, then undo and redo.
type Step<'a> = Box<dyn Fn(&mut Session) + 'a>;
let steps: [(&str, Step<'_>); 5] = [
("rotate", Box::new(|s: &mut Session| s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap())),
("comment", Box::new(move |s: &mut Session| s.apply(id, comment()).unwrap())),
("watermark", Box::new(move |s: &mut Session| s.apply(id, watermark()).unwrap())),
("undo", Box::new(|s: &mut Session| drop(s.undo(id).unwrap()))),
("redo", Box::new(|s: &mut Session| drop(s.redo(id).unwrap()))),
];
for (name, step) in steps {
let (bytes, display) = {
let doc = s.get(id).unwrap();
(Arc::downgrade(&doc.bytes), Arc::downgrade(&doc.display))
};
step(&mut s);
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while bytes.upgrade().is_some() || display.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "{name}: the previous working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
// What is shown is still the current state.
let doc = s.get(id).unwrap();
assert_eq!(doc.info.annotations.len(), 1);
let _ = shown(&s, id, 0);
}
#[test]
fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
let mut s = Session::new();
let mut px = image::RgbaImage::new(3, 2);
px.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
px.put_pixel(2, 1, image::Rgba([0, 0, 255, 255]));
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(px.clone()).write_to(&mut png, image::ImageFormat::Png).unwrap();
let pic = s.create_from_images(&[("p.png".into(), png.into_inner())]).unwrap();
let id = s.open("p.pdf", None, pic, None).unwrap();
let d = s.get(id).unwrap();
let r = d.page_images(0)[0].rect;
assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
assert_eq!(d.page_image_at(0, [r[2] + 10.0, r[3] + 10.0]), None);
assert_eq!(d.page_image_at(7, [0.0, 0.0]), None);
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(rgba, px.into_raw());
assert!(d.page_image_rgba(0, 1).is_err());
}
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
/// them), with a caption over it and an empty full-page Form on top.
#[test]
fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
let jpeg = jpeg_bytes();
let content = b"q 100 0 0 100 50 50 cm /Fm1 Do Q BT /F1 12 Tf 60 100 Td (Caption over the picture) Tj ET /Fm2 Do".to_vec();
let form = b"/Im1 Do".to_vec();
let stream = |dict: &str, data: &[u8]| {
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
o.extend_from_slice(data);
o.extend_from_slice(b"\nendstream");
o
};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Fm1 6 0 R /Fm2 9 0 R >> >> >>".to_vec(),
stream("", &content),
stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im1 7 0 R /Tiny 8 0 R >> >>", &form),
stream("/Type /XObject /Subtype /Image /Width 3 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode", &jpeg),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", &[255, 0, 0]),
stream("/Type /XObject /Subtype /Form /BBox [0 0 200 300]", b""),
];
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", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
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());
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(out), None).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.page_images(0).len(), 2, "{:?}", d.page_images(0));
// Under the caption, inside the picture: the Form holding it, not the empty Form on top.
assert_eq!(d.page_image_at(0, [100.0, 100.0]), Some(0));
assert_eq!(d.page_image_at(0, [10.0, 10.0]), None, "the empty full-page Form holds no picture");
// The largest picture in the group, as the JPEG stored in the PDF.
assert_eq!(d.page_image_copy_file(0, 0).unwrap(), ("jpg", jpeg));
assert!(d.page_image_copy_file(0, 1).is_err());
}

View File

@ -1,78 +0,0 @@
//! Explicit document-font discovery. Interface fallbacks never enter this catalogue.
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub struct FontFile {
pub path: PathBuf,
pub family: String,
pub face: String,
}
/// Standard user-installed font folders; discovery is performed only on request.
pub fn installed_directories() -> Vec<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
let mut dirs = vec![PathBuf::from("/Library/Fonts")];
if let Some(home) = home {
dirs.push(home.join("Library/Fonts"));
}
dirs
} else if cfg!(windows) {
let mut dirs = std::env::var_os("WINDIR").map(|p| PathBuf::from(p).join("Fonts")).into_iter().collect::<Vec<_>>();
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(local).join("Microsoft/Windows/Fonts"));
}
dirs
} else {
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
if let Some(home) = home {
dirs.extend([home.join(".local/share/fonts"), home.join(".fonts")]);
}
dirs
}
}
/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed.
/// The caller controls roots (automation resolves confinement before calling this).
pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
use std::io::Read;
let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect();
let mut files = Vec::new();
let mut entries = 0usize;
let mut budget = 64u64 << 20;
let mut seen = std::collections::HashSet::new();
while let Some((path, depth)) = pending.pop() {
if entries >= 4096 || budget == 0 {
break;
}
entries += 1;
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
if meta.file_type().is_symlink() || !seen.insert(path.clone()) {
continue;
}
if meta.is_dir() && depth < 4 {
if let Ok(children) = std::fs::read_dir(&path) {
let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len()));
pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1)));
}
} else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) {
if meta.len() > budget {
continue;
}
budget = budget.saturating_sub(meta.len());
let mut bytes = Vec::new();
let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes));
if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
continue;
}
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
files.push(FontFile { path, family, face });
}
}
}
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
files
}
fn font_extension(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
}

View File

@ -1,78 +0,0 @@
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone.
use std::sync::Arc;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage};
use crate::{Document, guard};
pub struct TextEditPreview {
page: usize,
bytes: Arc<Vec<u8>>,
config: RenderConfig,
}
impl TextEditPreview {
pub fn renderer(&self) -> RenderPool {
RenderPool::new(self.bytes.clone(), 1, self.config.clone())
}
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone());
let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
}
impl Document {
/// Measure a draft without changing bytes, dirty state, or undo history.
pub fn text_edit_layout(
&self,
page: usize,
block: usize,
text: &str,
style: &pdfcraft_edit::BlockStyle,
) -> Result<pdfcraft_edit::text::TextEditLayout, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
pdfcraft_edit::text::text_edit_layout(&editor.cos, page, block, text, style).map_err(|e| e.to_string())
})?
}
/// Embedded source faces for a text editor, bounded and validated as sfnt programs.
/// This is read-only; no source font is installed or exported.
pub fn text_editor_fonts(&self, page: usize) -> Vec<pdfcraft_edit::text::TextEditorFont> {
self.editor.as_ref().and_then(|e| pdfcraft_edit::text::text_editor_fonts(&e.cos, page).ok()).unwrap_or_default()
}
/// Preparing the background never changes working bytes, dirty state or undo history.
pub fn text_edit_preview(&self, page: usize, block: usize) -> Result<TextEditPreview, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
pdfcraft_edit::clear_block(&mut cos, page, block).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
/// The page beneath an added item while it is dragged: `index` counts the page's added
/// items, as `Edit::DeleteContent` does. Read-only, like [`Self::text_edit_preview`].
pub fn added_content_preview(&self, page: usize, index: usize) -> Result<TextEditPreview, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
}

View File

@ -8,8 +8,8 @@
use std::collections::HashMap;
use std::sync::Arc;
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode};
use pdfcraft_js::xfa_session::{Script, XfaSession};
use pdfcraft_js::formcalc::run_formcalc_within;
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, run_xfa_within};
use pdfcraft_xfa::model::Template;
use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent};
@ -258,15 +258,9 @@ struct Runner<'a, 'b> {
/// Field values to write, by name (the last wins).
field_changes: std::collections::BTreeMap<String, Vec<String>>,
by_som: Option<HashMap<String, String>>,
/// Widget names by the data node they bind to (built with `by_som`).
by_path: Option<HashMap<pdfcraft_xfa::DataPath, Vec<String>>>,
fields: Option<HashMap<String, FieldInfo>>,
/// The live form as last built; dropped when a script changed the form.
cached: Option<LiveForm>,
/// The script engine for this event: one thread for all its scripts, each run in a fresh
/// context on the form as it is then (`cached`, kept up to date in place for values and
/// rebuilt when the structure changed).
session: Option<XfaSession>,
changes: Changes,
}
@ -291,42 +285,12 @@ impl<'a, 'b> Runner<'a, 'b> {
pending: Vec::new(),
field_changes: Default::default(),
by_som: None,
by_path: None,
fields: None,
cached: None,
session: None,
changes: Changes::default(),
}
}
/// The live form no longer matches the data (its structure changed): rebuilt for the next
/// script.
fn invalidate(&mut self) {
self.cached = None;
}
/// The script engine for this event.
fn session(&mut self) -> &mut XfaSession {
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
self.session.get_or_insert_with(|| XfaSession::new(doc_info))
}
/// Give the object at `som` the value `text` in the data and in the live form (refreshed
/// in place, as the tree builder would set it; a value changes nothing else, so the tree
/// is not rebuilt for it).
fn value_op(&mut self, som: &str, path: pdfcraft_xfa::DataPath, text: String) {
let op = pdfcraft_xfa::DataOp::Value { path, text };
self.data.get_or_insert_with(Default::default).apply(&op);
self.pending.push(op);
let refreshed = match self.cached.as_mut() {
Some(live) => pdfcraft_xfa::refresh_value(&mut live.root, self.tpl, self.data.as_ref(), som).is_some(),
None => true,
};
if !refreshed {
self.invalidate();
}
}
fn live(&mut self) -> &LiveForm {
if self.cached.is_none() {
let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
@ -344,26 +308,11 @@ impl<'a, 'b> Runner<'a, 'b> {
fn by_som(&mut self) -> &HashMap<String, String> {
if self.by_som.is_none() {
let by_som = pdfcraft_xfa::fields_by_som(self.doc);
let mut by_path: HashMap<pdfcraft_xfa::DataPath, Vec<String>> = HashMap::new();
for (som, name) in &by_som {
by_path.entry(pdfcraft_xfa::data_path(self.tpl, som)).or_default().push(name.clone());
}
for names in by_path.values_mut() {
names.sort();
}
self.by_path = Some(by_path);
self.by_som = Some(by_som);
self.by_som = Some(pdfcraft_xfa::fields_by_som(self.doc));
}
self.by_som.get_or_insert_with(Default::default)
}
/// The widgets bound to the data node at `path`, in name order.
fn widgets_at(&mut self, path: &pdfcraft_xfa::DataPath) -> Vec<String> {
self.by_som();
self.by_path.as_ref().and_then(|m| m.get(path)).cloned().unwrap_or_default()
}
fn fields(&mut self) -> &mut HashMap<String, FieldInfo> {
if self.fields.is_none() {
self.fields = Some(field_infos(self.doc));
@ -397,71 +346,62 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
/// Change the data's structure (and the in-memory copy scripts read): the form is rebuilt
/// for the next script.
/// Change the data (and the in-memory copy scripts read).
fn data_op(&mut self, op: pdfcraft_xfa::DataOp) {
self.data.get_or_insert_with(Default::default).apply(&op);
self.pending.push(op);
self.invalidate();
self.cached = None;
}
/// Give the object at `som` the value `value`: in the data, and in every widget bound to
/// that data node (each valued by its own kind: a check box by its on state, a list box by
/// lines). Returns whether anything changed.
/// Give the object at `som` the value `value` (field `name` when it has a widget). Returns
/// whether it changed.
fn set_value(&mut self, som: &str, value: &str) -> bool {
use pdfcraft_forms::FieldKind as K;
let path = pdfcraft_xfa::data_path(self.tpl, som);
let names = self.widgets_at(&path);
// The data text: the widget at `som` says how its kind writes the value; without one,
// the first widget bound to the node, else the value itself.
let primary = self.by_som().get(som).cloned().or_else(|| names.first().cloned());
let mut text = value.to_string();
let mut changed = false;
for name in &names {
let Some(info) = self.fields().get(name).cloned() else { continue };
let new_value: Vec<String> = match info.kind {
K::CheckBox => {
if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state {
vec![info.on_state.clone()]
} else {
Vec::new()
}
}
K::Radio => {
let v = value.trim();
if v.is_empty() { Vec::new() } else { vec![v.to_string()] }
}
// Read-only fields still take values from scripts (calculated totals are
// read-only); buttons and signatures hold none.
K::PushButton | K::Signature => continue,
K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(),
_ => vec![value.to_string()],
};
let same = match info.kind {
K::CheckBox => info.value.is_empty() == new_value.is_empty(),
K::Radio => info.value.first() == new_value.first(),
K::List if info.multi => info.value == new_value,
_ => info.value.first().map_or(value.is_empty(), |v| v == value),
};
let updated = FieldInfo { value: new_value.clone(), ..info };
if primary.as_deref() == Some(name.as_str()) {
text = data_text(&updated);
let name = self.by_som().get(som).cloned();
let Some(name) = name else {
// No widget (a hidden field, a draw, a row not laid out yet): the data alone.
if self.data.as_ref().and_then(|d| d.text_at(&path)) == Some(value) {
return false;
}
if same {
continue;
}
self.fields().insert(name.clone(), updated);
self.field_changes.insert(name.clone(), new_value);
changed = true;
}
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
self.value_op(som, path, text);
changed = true;
}
if changed {
self.data_op(pdfcraft_xfa::DataOp::Value { path, text: value.to_string() });
self.changes.values += 1;
return true;
};
let Some(info) = self.fields().get(&name).cloned() else { return false };
use pdfcraft_forms::FieldKind as K;
let new_value: Vec<String> = match info.kind {
K::CheckBox => {
if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state {
vec![info.on_state.clone()]
} else {
Vec::new()
}
}
K::Radio => {
let v = value.trim();
if v.is_empty() { Vec::new() } else { vec![v.to_string()] }
}
// Read-only fields still take values from scripts (calculated totals are read-only).
K::PushButton | K::Signature => return false,
K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(),
_ => vec![value.to_string()],
};
let same = match info.kind {
K::CheckBox => info.value.is_empty() == new_value.is_empty(),
K::Radio => info.value.first() == new_value.first(),
K::List if info.multi => info.value == new_value,
_ => info.value.first().map_or(value.is_empty(), |v| v == value),
};
if same {
return false;
}
changed
let updated = FieldInfo { value: new_value.clone(), ..info };
let text = data_text(&updated);
self.fields().insert(name.clone(), updated);
self.field_changes.insert(name, new_value);
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.changes.values += 1;
true
}
/// Write what the scripts changed so far: the data as one rewrite, the field values, the
@ -492,7 +432,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if wrote {
// What the packet now holds (a value it could not take is reported above).
self.data = pdfcraft_xfa::data_of(self.doc);
self.invalidate();
self.cached = None;
}
Ok(())
}
@ -515,9 +455,8 @@ impl<'a, 'b> Runner<'a, 'b> {
crate::xfa_redraw(self.doc)?;
self.console(crate::undrawn_field_warnings(self.doc));
self.by_som = None;
self.by_path = None;
self.fields = None;
self.invalidate();
self.cached = None;
Ok(())
}
@ -551,7 +490,7 @@ impl<'a, 'b> Runner<'a, 'b> {
self.changes.values += 1;
}
}
self.invalidate();
self.cached = None;
Ok(())
}
@ -591,7 +530,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) {
self.ov_dirty = true;
self.changes.visibility += 1;
self.invalidate();
self.cached = None;
relayout = true;
}
}
@ -599,7 +538,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if self.ov.access.insert(som.clone(), access.clone()).as_ref() != Some(access) {
self.ov_dirty = true;
self.changes.visibility += 1;
self.invalidate();
self.cached = None;
relayout = true;
}
}
@ -647,23 +586,17 @@ impl<'a, 'b> Runner<'a, 'b> {
return Ok(None);
}
self.runs += 1;
#[cfg(test)]
self.check_live();
let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };
let root = to_js(&self.live().root);
let script = Script {
source: ev.script.clone(),
formcalc: ev.formcalc,
event,
root,
limits: limits_for(&ev.activity),
timeout: timeout_for(&ev.activity),
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
let root_js = to_js(&self.live().root);
let (limits, timeout) = (limits_for(&ev.activity), timeout_for(&ev.activity));
let o = if ev.formcalc {
run_formcalc_within(&ev.script, &event, &doc_info, root_js, limits, timeout)
} else {
run_xfa_within(&ev.script, &event, &doc_info, root_js, limits, timeout)
};
let o = self.session().run(script);
if o.abandoned {
self.run.ran_away = true;
// The thread is left to the runaway script: nothing more is sent to it.
self.session = None;
}
self.console(o.console.iter().cloned());
for n in &o.notes {
@ -702,15 +635,12 @@ impl<'a, 'b> Runner<'a, 'b> {
/// End the event: write what is left, lay the form out if a capped relayout is owed, and
/// report what the limits cut.
fn finish(mut self) -> Result<Changes, String> {
#[cfg(test)]
self.check_live();
if self.relayouts_skipped {
self.relayouts = 0;
self.relayouts_skipped = false;
self.relayout()?;
}
self.flush()?;
self.session = None;
if self.skipped > 0 {
let why = if self.run.ran_away {
"one ran too long".to_string()
@ -728,42 +658,6 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
impl Runner<'_, '_> {
/// The live form kept up to date in place must be the form a fresh build gives: every
/// field's SOM, value, presence and access (tests run this before every script and at the
/// end of every event). A difference is a bug in the in-place refresh, so the test binary
/// is ended at once: a panic here would be caught by the engine's guard and turned into a
/// script error the test might not look at.
#[cfg(test)]
fn check_live(&mut self) {
let Some(live) = self.cached.as_ref() else { return };
let fresh = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
let (mut have, mut want) = (Vec::new(), Vec::new());
collect_fields(&live.root, &mut have, 0);
collect_fields(&fresh.root, &mut want, 0);
if have != want {
let differ: Vec<String> =
have.iter().zip(want.iter()).filter(|(a, b)| a != b).take(5).map(|(a, b)| format!("live {a:?} vs fresh {b:?}")).collect();
eprintln!("the live form diverged from a fresh build ({} vs {} fields): {}", have.len(), want.len(), differ.join("; "));
std::process::abort();
}
}
}
/// Every field's (SOM, value, presence, access), in document order.
#[cfg(test)]
fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>, depth: usize) {
if depth > 64 {
return;
}
if n.kind == Some(NodeKind::Field) {
out.push((n.som.clone(), n.value.clone(), n.presence.clone(), n.access.clone()));
}
for c in &n.children {
collect_fields(c, out, depth + 1);
}
}
/// On open: initialize scripts, then calculations. Returns what they changed.
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);

Some files were not shown because too many files have changed in this diff Show More