diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 838af4524c..f3e2b20c7e 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -27,7 +27,8 @@ permissions:
jobs:
windows-arm64:
- uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: pdfcraft
app-dir: PdfCraft
diff --git a/.gitignore b/.gitignore
index 7834b668cb..f7922460c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ plan/
/assets/models
# A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here).
/craft-fonts
+.claude/
diff --git a/AGENTS.md b/AGENTS.md
index 10c0958840..98758c5dbd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -139,3 +139,8 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
+
+## CI workflows
+- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
+- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 489247559d..7dcd882f84 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -2879,7 +2879,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Master artwork; every other app-icon file is rendered from it (packaging/icons.sh)"
-sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d"
+sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da"
[[asset]]
path = "assets/app-icon/pdfcraft-small.svg"
@@ -2890,7 +2890,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "README header and assets/app-icon/README.md"
-sha256 = "b036304c11ef5dcad36636d06ca0bf331b51e47650d25d3c916a0e1eabf544c7"
+sha256 = "b524df1a583f8034a5a23836955e4fb423e0149429d695e80d95e5056f408678"
[[asset]]
path = "assets/app-icon/pdfcraft-1024.png"
@@ -2901,7 +2901,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs)"
-sha256 = "da577d3f21fbcf5c739067787a9e683be2b064d76813b5d5fec9074a7a106538"
+sha256 = "68ac6730b8990d07503351d32eacea81e9ec171a7a6f09daf52d6555d8245c8a"
[[asset]]
path = "assets/app-icon/pdfcraft.icns"
@@ -2912,7 +2912,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "macOS app bundle icon"
-sha256 = "2425148222873de241d267aba667a8b234ecdd0149f2ca8bb6e79443b743b1e8"
+sha256 = "fca7e7a0e34cc5c5ecae5bca97ee896aac68c4a34b088bc3a0b3271484f23974"
[[asset]]
path = "assets/app-icon/pdfcraft.ico"
@@ -2923,7 +2923,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)"
-sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5"
+sha256 = "709271d66e07710a3f785ea21b0c85c5228eaf8bebe5058bc0f71f9ef20749eb"
[[asset]]
path = "assets/app-icon/pdfcraft-document.svg"
@@ -2934,7 +2934,7 @@ licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images"
-sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b"
+sha256 = "c129f1b538ce16e3c3fa0d89d3301038c51249ba5d61bfd98c46e69cfdf5c316"
[[asset]]
path = "assets/app-icon/pdfcraft-document.ico"
@@ -2945,7 +2945,7 @@ licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations"
-sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6"
+sha256 = "910be5e0ecdc94ce00368077ca1bef449bd5d62a75516feeb37884296c6fac4d"
[[asset]]
path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png"
@@ -2956,7 +2956,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "9b77095cbe75dcba6b74a1fdcd4064812850efaf7a12edebefc9ded5eabb49da"
+sha256 = "660d2aace45ec1a28bc7e3708eab71ffee5cb1203f57a6256f7bd7c727193567"
[[asset]]
path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png"
@@ -2967,7 +2967,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "a4cd466687f6c5cb59eef3fc564bdba1c8f0d9e324754eeebab82f18823efae7"
+sha256 = "1989441bdcc8d1df0d73cfe7eb836e9211abaa0fd7fd6ec7e4634bfcd472995d"
[[asset]]
path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png"
@@ -2978,7 +2978,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "e22279013a5ed5c02571e1e4e9bc4cef9aa0847fcb733a285b2916fabd16bf78"
+sha256 = "7fd1ac555f7f6b13ef3fba2696dfb86900cfcb7f7b669aece38590bf2306cf2e"
[[asset]]
path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png"
@@ -2989,7 +2989,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "55daf2d5575f6c4e4c4975c9f1c74231178f1801963a8b19f68dfa9bc50cb22f"
+sha256 = "784ef3bd9f9e479cb2df22a0aee259a441a9c358390e646639c3a0ed79d41288"
[[asset]]
path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png"
@@ -3000,7 +3000,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "2975c200f341e247b5fa18f568fb27d390e3fa01e9b2ad37acb48d0e1cc8a124"
+sha256 = "deb1a9f849549d3f0652fb6d3c30b6eb6ea54498eaea8c1007d901bbafe7067c"
[[asset]]
path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png"
@@ -3011,7 +3011,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "6ef927ca252b40bc9b5d8843d84137334362f8aa1acbb1bf8c15a02affd25b2b"
+sha256 = "1933bbeaea290497facd328e917dafcc433d0c0caa9f1e207aa45ac1e430898c"
[[asset]]
path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png"
@@ -3022,7 +3022,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs)"
-sha256 = "cde7ee8c5ee38f9fc40a429fd9e6d99a035bcd321701fce331bb13bac126614d"
+sha256 = "47094c09fb77eb35d465eee46445626a4d78c7ef50629cc7d04a602de3e144e9"
[[asset]]
path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png"
@@ -3033,7 +3033,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "5ede733e93b96f4eb8d94299232cf2f9605fa19b0d913f95fe9828c316a2d066"
+sha256 = "dea8010a8b41011aba054d3bfb8b790da57bc958c60869f508e1bbd288d3f280"
[[asset]]
path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg"
@@ -3044,7 +3044,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d"
+sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da"
[[asset]]
path = "docs/images/pdfcraft-bookmarks.png"
@@ -3341,7 +3341,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py"
-sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a"
+sha256 = "182663e4d1c4fabbb7ab2e1f0d4e991a8e0859b4a8dee6934d3e25d77ea020af"
[[asset]]
@@ -3353,7 +3353,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile"
-sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
+sha256 = "bedf1b23707b9e34157440e83f65464ff314fa9f6b1d39c7a8930d1645c2f8be"
[[fetched]]
file = "text-detection.onnx"
diff --git a/CLAUDE.md b/CLAUDE.md
index f51a96d56e..b6c91b1fe6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,6 +22,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
+- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
+- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
@@ -39,6 +41,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **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).
+ - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -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.
- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`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.
@@ -55,3 +58,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## Current bootstrap debt (tracked in STATUS.md)
- `pdfcraft-render` renders through the `hayro` crate directly and inspects documents through `lopdf`. Both get replaced by `cos` / `model` / the DisplayList device (M1–M2, ADR-0004).
+
+## CI workflows
+- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
+- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
diff --git a/Cargo.lock b/Cargo.lock
index b201da8413..cfcb9dc362 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -229,7 +229,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1178,17 +1178,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
[[package]]
name = "craft-i18n"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1211,7 +1211,7 @@ dependencies = [
[[package]]
name = "craft-keymap"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"egui",
"serde",
@@ -1221,7 +1221,7 @@ dependencies = [
[[package]]
name = "craft-menu"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-keymap",
"craft-quit",
@@ -1234,7 +1234,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.9.1",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"dispatch2",
"egui",
@@ -1258,7 +1258,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1279,13 +1279,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
+ "image",
"log",
"plist",
"semver",
@@ -2015,7 +2016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2731,6 +2732,7 @@ version = "0.3.0"
dependencies = [
"fearless_simd",
"hayro-ccitt",
+ "image",
]
[[package]]
@@ -4114,7 +4116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4205,7 +4207,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"chrono",
"craft-update",
@@ -4229,7 +4231,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4241,7 +4243,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4252,7 +4254,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4263,7 +4265,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"base64 0.23.1",
"flate2",
@@ -4285,7 +4287,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"base64 0.23.1",
"pdfcraft-audit",
@@ -4303,11 +4305,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.14.0"
+version = "0.15.0"
[[package]]
name = "pdfcraft-content"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4315,7 +4317,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4330,14 +4332,18 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"encoding_rs",
+ "hayro-ccitt",
+ "hayro-jbig2",
"hayro-syntax",
"image",
"pdfcraft-cos",
+ "pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
+ "pdfcraft-xfa",
"png 0.18.1",
"thiserror 2.0.21",
"tiff",
@@ -4346,7 +4352,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"aes",
"md-5",
@@ -4358,7 +4364,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4367,16 +4373,18 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "sha2 0.11.1",
"thiserror 2.0.21",
"unicode-bidi",
]
[[package]]
name = "pdfcraft-engine"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"chrono",
"craft-docpath",
+ "getrandom 0.4.3",
"icu_properties",
"idna",
"image",
@@ -4414,16 +4422,19 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
+ "fax",
"flate2",
+ "hayro-ccitt",
+ "hayro-jbig2",
"proptest",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -4431,7 +4442,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4441,7 +4452,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4454,11 +4465,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.14.0"
+version = "0.15.0"
[[package]]
name = "pdfcraft-js"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"boa_engine",
"chrono",
@@ -4466,7 +4477,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4479,14 +4490,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4497,7 +4508,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4506,17 +4517,18 @@ dependencies = [
"pdfcraft-filters",
"pdfcraft-model",
"pdfcraft-render",
+ "proptest",
"thiserror 2.0.21",
"zune-jpeg",
]
[[package]]
name = "pdfcraft-oracle"
-version = "0.14.0"
+version = "0.15.0"
[[package]]
name = "pdfcraft-organize"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4527,7 +4539,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4537,18 +4549,18 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
- "roxmltree 0.20.0",
+ "roxmltree 0.21.2",
]
[[package]]
name = "pdfcraft-print"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4560,12 +4572,13 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-edit",
+ "pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-model",
@@ -4576,7 +4589,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4595,7 +4608,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4631,11 +4644,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.14.0"
+version = "0.15.0"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"arboard",
"base64 0.23.1",
@@ -4687,7 +4700,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"eframe",
"egui",
@@ -4699,27 +4712,27 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"base64 0.23.1",
"image",
"pdfcraft-cos",
"pdfcraft-fonts",
"png 0.18.1",
- "roxmltree 0.20.0",
+ "roxmltree 0.21.2",
"sha2 0.11.1",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-xfdf"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
- "roxmltree 0.20.0",
+ "roxmltree 0.21.2",
"thiserror 2.0.21",
]
@@ -5258,7 +5271,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5795,7 +5808,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5865,7 +5878,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6752,10 +6765,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.4.3",
+ "getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7350,8 +7363,6 @@ dependencies = [
[[package]]
name = "vello_cpu"
version = "0.0.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
dependencies = [
"bytemuck",
"glifo 0.1.1",
@@ -7868,7 +7879,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8595,9 +8606,9 @@ dependencies = [
[[package]]
name = "zip"
-version = "9.1.0"
+version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
+checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index 99f9fa251b..1b04ab5263 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.14.0"
+version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,51 +13,52 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
-craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
+craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] }
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
-craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
-craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
-craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
+craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
+craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", default-features = false }
# Shared system spooler; document composition remains in pdfcraft-print.
-craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# Whether a path stored in a document may be opened (GoToR link targets).
-craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
-craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
+# craft-update: app-specific pretend versions apply to startup and manual checks.
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
-pdfcraft-model = { path = "crates/model", version = "0.14.0" }
-pdfcraft-content = { path = "crates/content", version = "0.14.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
-pdfcraft-print = { path = "crates/print", version = "0.14.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
-pdfcraft-js = { path = "crates/js", version = "0.14.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
-pdfcraft-export = { path = "crates/export", version = "0.14.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
-pdfcraft-create = { path = "crates/create", version = "0.14.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
-pdfcraft-render = { path = "crates/render", version = "0.14.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.15.0" }
+pdfcraft-filters = { path = "crates/filters", version = "0.15.0" }
+pdfcraft-cos = { path = "crates/cos", version = "0.15.0" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" }
+pdfcraft-organize = { path = "crates/organize", version = "0.15.0" }
+pdfcraft-measure = { path = "crates/measure", version = "0.15.0" }
+pdfcraft-annot = { path = "crates/annot", version = "0.15.0" }
+pdfcraft-audit = { path = "crates/audit", version = "0.15.0" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" }
+pdfcraft-model = { path = "crates/model", version = "0.15.0" }
+pdfcraft-content = { path = "crates/content", version = "0.15.0" }
+pdfcraft-redact = { path = "crates/redact", version = "0.15.0" }
+pdfcraft-print = { path = "crates/print", version = "0.15.0" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" }
+pdfcraft-sign = { path = "crates/sign", version = "0.15.0" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" }
+pdfcraft-js = { path = "crates/js", version = "0.15.0" }
+pdfcraft-compare = { path = "crates/compare", version = "0.15.0" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" }
+pdfcraft-export = { path = "crates/export", version = "0.15.0" }
+pdfcraft-edit = { path = "crates/edit", version = "0.15.0" }
+pdfcraft-create = { path = "crates/create", version = "0.15.0" }
+pdfcraft-forms = { path = "crates/forms", version = "0.15.0" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" }
+pdfcraft-render = { path = "crates/render", version = "0.15.0" }
+pdfcraft-engine = { path = "crates/engine", version = "0.15.0" }
+pdfcraft-platform = { path = "crates/platform", version = "0.15.0" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" }
+pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false }
# pdf
hayro = "0.7.1"
@@ -65,6 +66,11 @@ hayro-syntax = "0.7.2"
lopdf = "0.45"
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
weezl = "0.2"
+# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
+fax = "0.2.7"
+# Independent CCITT and JBIG2 codecs, already in the tree through hayro.
+hayro-ccitt = "0.3"
+hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
@@ -91,6 +97,9 @@ web-time = "1.1"
unsafe_code = "forbid"
[workspace.lints.clippy]
+cognitive_complexity = "deny"
+too_many_arguments = "deny"
+wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
@@ -125,6 +134,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
vello_common = { path = "vendor/vello_common" }
+vello_cpu = { path = "vendor/vello_cpu" }
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
diff --git a/README.md b/README.md
index 1766e26ebb..9599a54844 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
PdfCraft
@@ -21,10 +21,10 @@
-
-
-
-
+
+
+
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 017b8db8e3..d30325d3e3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,5 +1,6 @@
# PdfCraft roadmap
+> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged)
@@ -7,9 +8,11 @@
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
@@ -29,6 +32,7 @@
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -103,8 +107,8 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
| | Value | Kind |
|---|---|---|
-| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
-| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
+| **Feature breadth** | **≈ 67%** tier-weighted (57.0% unweighted: 423 shipped + 75 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
+| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
@@ -159,7 +163,7 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef
| H Digital signatures | 62.2% | 45% | 40–70 |
| I Scan and OCR | 23.1% | 15% | 40–75 |
| J Create and export | 39.7% | 25% | 55–100 |
-| K Optimize and standards | 34.5% | 20% | 65–115 |
+| K Optimize and standards | 35.3% | 20% | 65–115 |
| L Print | 39.7% | 35% | 30–55 |
| M Accessibility | 63.1% | 30% | 40–70 |
| N Automation, preferences, misc | 30.0% | 35% | 25–45 |
@@ -208,6 +212,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged.
+
- **2026-10-28 (M2, PR #4172):** Integrated main 5af10276, preserving both roadmap histories and reviewing auto-merged export tests and accessibility UI changes. Validation: 488 scoped tests passed (18 engine, 412 renderer unit, 43 renderer integration, 15 Combine; one existing renderer integration test ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Viewer-only CJK fallback and print/output font policy remain unchanged; estimates unchanged.
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
@@ -216,10 +222,21 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
+- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
+- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
+- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
+- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
+- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
+- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too.
+- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`.
+- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images.
+- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
+- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
+- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
@@ -235,6 +252,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
Estimates unchanged.
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
+- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
@@ -317,6 +335,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
+- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background turns the fill back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first; then it says which pages keep it off. The copy's tag holds no reference to the original stream, which had kept text a redaction or Remove Hidden Information removed in the saved file. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
@@ -468,6 +487,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
+- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
- **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 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
@@ -776,3 +796,9 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-26 (M2, PR #4172 documentation review):** Added function documentation for installed-font script selection, bounded candidate reads, platform paths, resolver precedence and regression contracts. No runtime behavior or estimate change.
- **2026-10-27 (M2, PR #4172 conflict resolution):** Integrated main `9a9813bf` and its engine module split. Opening, layer changes and session renderer rebuilds retain display-only CJK fallback; output configuration and print-preview cache isolation are preserved. Validation passed: 16 scoped engine regressions, 412 renderer unit tests, 43 renderer integration tests (one existing corpus case ignored), 15 Combine tests, scoped Clippy, formatting, layers/assets/parity and renderer/engine WASM checks. Estimates unchanged.
+## Pages sidebar keyboard refresh (2026-10-30)
+
+PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged.
+## Button export-value refresh (2026-10-30)
+
+PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged.
diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs
index 1821f85826..62993ad6dc 100644
--- a/apps/pdfcraft-cli/src/check.rs
+++ b/apps/pdfcraft-cli/src/check.rs
@@ -1,6 +1,8 @@
use super::commands::{parse_dpi, read};
use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options};
-use super::*;
+use super::{
+ Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line,
+};
/// 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.
diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs
index 5efec8331c..9d0b2057f0 100644
--- a/apps/pdfcraft-cli/src/commands.rs
+++ b/apps/pdfcraft-cli/src/commands.rs
@@ -2,7 +2,7 @@ use super::options::{
COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional,
validate_options,
};
-use super::*;
+use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
pub(crate) fn read(path: &str) -> Result>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs
index ab1a3db34b..1ddbfabcd5 100644
--- a/apps/pdfcraft-cli/src/options.rs
+++ b/apps/pdfcraft-cli/src/options.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::CliError;
pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs
index 19eed21876..0726087d9d 100644
--- a/apps/pdfcraft-cli/src/run.rs
+++ b/apps/pdfcraft-cli/src/run.rs
@@ -1,7 +1,9 @@
#[cfg(feature = "mcp")]
use super::options::MCP_OPTIONS;
use super::options::{RUN_OPTIONS, flag, positional, validate_options};
-use super::*;
+#[cfg(feature = "mcp")]
+use super::{Arc, Path, Write, automation_args};
+use super::{CliError, stdout_line};
// ---- automation --------------------------------------------------------------------------------
diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs
index 9d12233528..dc419171b0 100644
--- a/apps/pdfcraft-cli/src/ui.rs
+++ b/apps/pdfcraft-cli/src/ui.rs
@@ -1,5 +1,5 @@
use super::options::{UI_OPTIONS, flag, positional, validate_options};
-use super::*;
+use super::{CliError, Duration, stdout_line};
// ---- UI control channel client -----------------------------------------------------------------
diff --git a/apps/pdfcraft-cli/tests/it/text.rs b/apps/pdfcraft-cli/tests/it/text.rs
index 3d39a9e284..b7fb2511b7 100644
--- a/apps/pdfcraft-cli/tests/it/text.rs
+++ b/apps/pdfcraft-cli/tests/it/text.rs
@@ -243,4 +243,40 @@ mod page_argument_tests {
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
}
+
+ #[test]
+ fn text_clips_glyphs_outside_the_crop_box() {
+ let dir = PageDir::new();
+ let path = dir.0.join("cropped.pdf");
+ let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET";
+ let objs: Vec> = 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 /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
+ format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
+ ];
+ let mut pdf = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(pdf.len());
+ pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ pdf.extend_from_slice(o);
+ pdf.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = pdf.len();
+ pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ std::fs::write(&path, pdf).unwrap();
+
+ for args in [vec![], vec!["--page", "1"]] {
+ let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap();
+ assert!(out.status.success(), "{args:?}: {out:?}");
+ assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
+ assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}");
+ }
+ }
}
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index f8e4e5b0d2..814730f344 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -196,7 +196,7 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
- if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) {
+ if let Some(result) = craft_update::self_update::finish(&pdfcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md
index 10fb2d4cf3..58ef07d37c 100644
--- a/assets/app-icon/README.md
+++ b/assets/app-icon/README.md
@@ -1,6 +1,6 @@
# PdfCraft app icon
-
+
**Creature:** a lion, in a frontal head-and-shoulders portrait, mane running off the bottom of the tile.
@@ -13,7 +13,7 @@ a full-bleed colour field, no frame or roundel, the animal looking at the viewer
|---|---|---|
| Ink | `#0b0b0c` | line work and the figure's contour |
| Paper | `#efe9dc` | the figure (the lion's silhouette) |
-| PdfCraft green (app colour) | `#12a58a` | the full-bleed field |
+| PdfCraft red (app colour) | `#b70228` | the full-bleed field |
**Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112` that clips everything. Windows and Linux
icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px body centred on a transparent
@@ -33,7 +33,7 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo).
| `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS |
| `pdfcraft.icns` | macOS icon (16–1024 px) |
| `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` |
-| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency |
+| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, red badge and outlined PDF lettering; no font dependency |
| `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` |
| `hicolor/x/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux |
| `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) |
diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png
index 20f4ca4393..b4ac96bc47 100644
Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png
index be76e69b86..c3f4315212 100644
Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png
index a00f8f9501..5ae21c7ded 100644
Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png
index be413a715a..766624ec8c 100644
Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png
index f9e2ef2b6e..ab6e8e9312 100644
Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png
index 73971ee5f9..55256b293d 100644
Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png
index 1d9ff4d11c..eb3699f9b8 100644
Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png
index 14444f01bc..9bba411e39 100644
Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
index 3a1de9f076..5a8017ec4f 100644
--- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
+++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/app-icon/pdfcraft-1024.png b/assets/app-icon/pdfcraft-1024.png
index 3c79b60279..d6cf213765 100644
Binary files a/assets/app-icon/pdfcraft-1024.png and b/assets/app-icon/pdfcraft-1024.png differ
diff --git a/assets/app-icon/pdfcraft-document.ico b/assets/app-icon/pdfcraft-document.ico
index 79967a800a..ec1ea4568e 100644
Binary files a/assets/app-icon/pdfcraft-document.ico and b/assets/app-icon/pdfcraft-document.ico differ
diff --git a/assets/app-icon/pdfcraft-document.svg b/assets/app-icon/pdfcraft-document.svg
index 603a740157..b9d2fbf3c9 100644
--- a/assets/app-icon/pdfcraft-document.svg
+++ b/assets/app-icon/pdfcraft-document.svg
@@ -4,10 +4,10 @@
-
-
+
+
-
+
diff --git a/assets/app-icon/pdfcraft-small.svg b/assets/app-icon/pdfcraft-small.svg
index fb35ea2912..a8d6517b54 100644
--- a/assets/app-icon/pdfcraft-small.svg
+++ b/assets/app-icon/pdfcraft-small.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/app-icon/pdfcraft.icns b/assets/app-icon/pdfcraft.icns
index 08369d5dd0..c41d1d10f0 100644
Binary files a/assets/app-icon/pdfcraft.icns and b/assets/app-icon/pdfcraft.icns differ
diff --git a/assets/app-icon/pdfcraft.ico b/assets/app-icon/pdfcraft.ico
index 6e1971d4a5..eedf34883a 100644
Binary files a/assets/app-icon/pdfcraft.ico and b/assets/app-icon/pdfcraft.ico differ
diff --git a/assets/app-icon/pdfcraft.svg b/assets/app-icon/pdfcraft.svg
index 3a1de9f076..5a8017ec4f 100644
--- a/assets/app-icon/pdfcraft.svg
+++ b/assets/app-icon/pdfcraft.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/clippy.toml b/clippy.toml
index e908d13581..31e54da30f 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -13,6 +13,7 @@ disallowed-methods = [
{ path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
{ path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
]
+cognitive-complexity-threshold = 30
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
diff --git a/contributors/people.toml b/contributors/people.toml
index dd75a07314..53cc2391ed 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -180,3 +180,6 @@ sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+
+[people.dylanmtaylor]
+sync_github_name = true
diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs
index 41c749ed3c..0fa31ccbb1 100644
--- a/crates/a11y/src/headings.rs
+++ b/crates/a11y/src/headings.rs
@@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap {
// Where each id's last glyph ended, in user space.
let mut ends: HashMap = HashMap::new();
let mut gs = TextState::default();
- let mut saved: Vec = Vec::new();
+ // With each saved state, the text and text line matrices: inside a text object `q`/`Q`
+ // save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
+ // errata, issue 368), as the renderer does.
+ let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new();
let mut unsaved = 0usize;
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
@@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap {
b"EMC" => {
stack.pop();
}
- b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()),
+ b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)),
b"q" => unsaved += 1,
b"Q" if unsaved > 0 => unsaved -= 1,
b"Q" => {
if let Some(saved) = saved.pop() {
- gs = saved;
+ (gs, tm, tlm) = saved;
}
}
b"cm" => {
diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs
index a6dcdba199..a70ffbf385 100644
--- a/crates/a11y/src/tests.rs
+++ b/crates/a11y/src/tests.rs
@@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() {
);
}
+/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as
+/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose
+/// glyphs continue after a `Q` stays one word.
+#[test]
+fn q_inside_a_text_object_restores_the_text_matrices() {
+ let doc = heading_page(&[
+ "BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(),
+ "BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(),
+ ]);
+ let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect();
+ assert_eq!(titles, ["Chapter One", "Chapter Two"]);
+}
+
#[test]
fn an_untagged_document_has_no_headings() {
let mut objs = headings_doc();
diff --git a/crates/annot/README.md b/crates/annot/README.md
index 8108201835..999baec20a 100644
--- a/crates/annot/README.md
+++ b/crates/annot/README.md
@@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten
add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?;
set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?;
move_annotation(&mut doc, page, index, dx, dy, &meta)?;
-set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?;
+set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?;
delete_annotation(&mut doc, page, index)?;
signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials
```
diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs
index 508d2bee45..abc746c0dd 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -373,16 +373,60 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>)
(caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size)))
}
+#[derive(Clone, Copy)]
+struct AppearanceContext<'a> {
+ dict: &'a Dict,
+ subtype: &'a [u8],
+ rect: [f64; 4],
+ cloudy: bool,
+ opacity: f64,
+ stroke: Option,
+ width: f64,
+}
+
+#[derive(Default)]
+struct AppearanceDrawing {
+ resources: Dict,
+ content: String,
+ tail: Vec,
+}
+
+impl AppearanceDrawing {
+ fn new(context: &AppearanceContext<'_>) -> Self {
+ let mut drawing = Self::default();
+ if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") {
+ drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs));
+ drawing.content.push_str("/GS0 gs\n");
+ }
+ drawing
+ }
+}
+
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(d: &Dict) -> Option {
+ let context = appearance_context(d)?;
+ let drawing = AppearanceDrawing::new(&context);
+ match context.subtype {
+ b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing),
+ b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing),
+ b"Stamp" => return draw_stamp(&context, drawing),
+ b"FreeText" => return draw_free_text(&context, drawing),
+ _ => {}
+ }
+ let mut drawing = drawing;
+ draw_body(&context, &mut drawing)?;
+ finish_appearance(&context, drawing)
+}
+
+fn appearance_context(d: &Dict) -> Option> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.
if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") {
return None;
}
- let subtype = d.name(b"Subtype")?.to_vec();
+ let subtype = d.name(b"Subtype")?;
let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?;
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
// Cloudy borders (§12.5.4) are drawn for polygons only.
@@ -390,520 +434,576 @@ pub fn build(d: &Dict) -> Option {
if cloudy && subtype != b"Polygon" {
return None;
}
- let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0);
- let stroke = color(d, b"C")?;
- let w = border_width(d);
- let mut res = Dict::new();
- let mut c = String::new();
- // Content after `c` that isn't UTF-8 (WinAnsi text).
- let mut tail: Vec = Vec::new();
- let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly");
- if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") {
- res.set(b"ExtGState".to_vec(), Object::Dict(gs));
- c.push_str("/GS0 gs\n");
- }
- match subtype.as_slice() {
- b"Text" => {
- let col = stroke.unwrap_or([1.0, 0.82, 0.0]);
- let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into());
- c.push_str(¬e_icon(&icon, col));
- return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res));
- }
- b"FileAttachment" => {
- let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
- let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into());
- c.push_str(&attach_icon(&icon, col));
- return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res));
- }
- _ if markup => {
- let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
- let col = stroke?;
- for quad in q.as_chunks::<8>().0.iter() {
- let p = |i: usize| (quad[2 * i], quad[2 * i + 1]);
- let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3));
- let h = (p1.0 - p3.0).hypot(p1.1 - p3.1);
- // A point a fraction `t` of the way from the bottom edge to the top edge.
- let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t);
- match subtype.as_slice() {
- b"Highlight" => {
- c.push_str(&rg(col));
- c.push_str(&format!(
- "{} {} m {} {} l {} {} l {} {} l h f\n",
- n(p1.0),
- n(p1.1),
- n(p2.0),
- n(p2.1),
- n(p4.0),
- n(p4.1),
- n(p3.0),
- n(p3.1)
- ));
- }
- b"Underline" | b"StrikeOut" => {
- let lw = (h * 0.07).clamp(0.5, 3.0);
- let t = if subtype == b"Underline" { 0.08 } else { 0.42 };
- let (a, b) = (at(p3, p1, t), at(p4, p2, t));
- c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1)));
- }
- _ => {
- // Squiggly: a zigzag along the bottom of the quad.
- let lw = (h * 0.05).clamp(0.5, 2.0);
- let amp = (h * 0.06).max(0.75);
- let len = (p4.0 - p3.0).hypot(p4.1 - p3.1);
- let step = (h / 4.0).max(1.5);
- let steps = ((len / step).ceil() as usize).clamp(1, 10_000);
- c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw)));
- for i in 0..=steps {
- let t = i as f64 / steps as f64;
- let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t);
- let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 };
- let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) };
- let (x, y) = (base.0 + ux * up, base.1 + uy * up);
- c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" }));
- }
- c.push_str("S\n");
- }
- }
- }
- }
- b"Redact" => {
- // While marked: the outline of each area (the fill comes when applied).
- let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
- let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
- c.push_str(&format!("{}1 w\n", rg_stroke(col)));
- for quad in q.as_chunks::<8>().0.iter() {
- c.push_str(&format!(
- "{} {} m {} {} l {} {} l {} {} l h S\n",
- n(quad[0]),
- n(quad[1]),
- n(quad[2]),
- n(quad[3]),
- n(quad[6]),
- n(quad[7]),
- n(quad[4]),
- n(quad[5])
- ));
- }
- }
- b"Square" | b"Circle" => {
- let fill = interior_color(d, None);
- if stroke.is_none() && fill.is_none() {
- return Some(form(rect, c.as_bytes(), res));
- }
- let inset = w / 2.0;
- let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset];
- if let Some(f) = fill {
- c.push_str(&rg(f));
- }
- if let Some(s) = stroke {
- c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d)));
- }
- if subtype == b"Square" {
- c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0)));
- } else {
- c.push_str(&ellipse(x0, y0, x1, y1));
- }
- c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) {
- (true, true) => "B\n",
- (true, false) => "f\n",
- (false, true) => "S\n",
- (false, false) => "n\n",
- });
- }
- b"Line" => {
- let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?;
- let col = stroke?;
- let ends: Vec> = match d.get(b"LE") {
- None => vec![b"None".to_vec(), b"None".to_vec()],
- Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?,
- };
- if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) {
- return None;
- }
- let fill = interior_color(d, Some(col)).unwrap_or(col);
- let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w);
- let [p, q] = layout.line;
- c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
- for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) {
- c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1)));
- }
- c.push_str("[] 0 d\n");
- for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) {
- if !line_end(&mut c, end, tip, from, w) {
- return None;
- }
- }
- if let Some((text, m)) = layout.caption {
- // The caption is black, in the standard font the measurement label uses.
- tail.extend(
- format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5]))
- .bytes(),
- );
- // WinAnsi bytes go into the stream as-is, not through UTF-8.
- tail.extend(literal(&win_ansi(&text)));
- tail.extend_from_slice(b" Tj ET Q\n");
- res.set(b"Font".to_vec(), helvetica_resource());
- }
- }
- b"Polygon" | b"PolyLine" => {
- let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?;
- let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
- let col = stroke;
- let closed = subtype == b"Polygon";
- let fill = if closed { interior_color(d, None) } else { None };
- let ends = if subtype == b"PolyLine" {
- match d.get(b"LE") {
- None => None,
- Some(o) => {
- let names: Vec> =
- o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?;
- if names.iter().any(|e| !known_ending(e)) {
- return None;
- }
- Some(names)
- }
- }
- } else {
- None
- };
- if let Some(f) = fill {
- c.push_str(&rg(f));
- }
- if let Some(s) = col {
- c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d)));
- }
- if cloudy {
- let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0);
- c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0)));
- } else {
- for (i, p) in pts.iter().enumerate() {
- c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
- }
- if closed {
- c.push_str("h\n");
- }
- }
- c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) {
- (true, true) => "B\n",
- (true, false) => "f\n",
- (false, true) => "S\n",
- (false, false) => "n\n",
- });
- if let Some(ends) = ends {
- let col = col?;
- let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None };
- let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None };
- let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None };
- let fill = interior_color(d, Some(col)).unwrap_or(col);
- c.push_str(&format!("[] 0 d\n{}", rg(fill)));
- if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) {
- return None;
- }
- }
- }
- b"Caret" => {
- // A filled caret: two curved flanks meeting at the top centre.
- let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
- let (caret, pilcrow) = caret_layout(d, rect);
- let [x0, y0, x1, y1] = caret;
- let (cx, h) = ((x0 + x1) / 2.0, y1 - y0);
- c.push_str(&format!(
- "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n",
- rg(col),
- n(x0),
- n(y0),
- n(cx - (x1 - x0) * 0.1),
- n(y0 + h * 0.2),
- n(cx),
- n(y1 - h * 0.25),
- n(cx),
- n(y1),
- n(cx),
- n(y1 - h * 0.25),
- n(cx + (x1 - x0) * 0.1),
- n(y0 + h * 0.2),
- n(x1),
- n(y0)
- ));
- if let Some((x, y, size)) = pilcrow {
- // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour.
- // 0xB6 is the pilcrow in WinAnsiEncoding.
- let mut out = c.into_bytes();
- out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes());
- out.push(0xB6);
- out.extend_from_slice(b") Tj ET\n");
- res.set(b"Font".to_vec(), helvetica_resource());
- return Some(form(rect, &out, res));
- }
- }
- b"Ink" => {
- let col = stroke?;
- let list = d.get(b"InkList")?.as_array()?;
- // `/BS` gives an ink stroke its width and dash pattern (Table 182).
- c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d)));
- for s in list {
- let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?;
- let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
- let Some(first) = pts.first() else { continue };
- c.push_str(&format!("{} {} m\n", n(first.0), n(first.1)));
- match pts.as_slice() {
- [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))),
- [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))),
- _ => c.push_str(&smooth_curve(&pts)),
- }
- c.push_str("S\n");
- }
- }
- b"Stamp" => {
- // A typed signature: filled outlines normalised to the rectangle.
- if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) {
- let [x0, y0, x1, y1] = rect;
- let (w, h) = (x1 - x0, y1 - y0);
- c.push_str(&rg(stroke.unwrap_or([0.0; 3])));
- for contour in outline {
- let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
- for (i, p) in v.as_chunks::<2>().0.iter().enumerate() {
- c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" }));
- }
- if v.len() >= 6 {
- c.push_str("h\n");
- }
- }
- c.push_str("f\n");
- return Some(form(rect, c.as_bytes(), res));
- }
- // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
- // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets
- // the standard counter-rotation as its form `/Matrix`: viewers fit the turned
- // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
- if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
- let [x0, y0, x1, y1] = rect;
- let (w, h) = (x1 - x0, y1 - y0);
- let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
- let place = if image {
- format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
- } else {
- let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
- format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0))
- };
- c.push_str(&format!("q {place} /Pic Do Q\n"));
- let mut xo = Dict::new();
- xo.set(b"Pic".to_vec(), Object::Ref(pic));
- res.set(b"XObject".to_vec(), Object::Dict(xo));
- let mut stream = form(rect, c.as_bytes(), res);
- let turn = picture_turn(d);
- if image && turn != 0 {
- // The page's view matrix without its offset: display axes in user space.
- let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
- let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0];
- stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect()));
- }
- return Some(stream);
- }
- // Only PdfCraft's own Fill & Sign marks are drawn here.
- let name = d.name(b"Name")?;
- let col = stroke.unwrap_or([0.0; 3]);
- // A mark or stamp placed on a turned page is drawn upright in its 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 it the right way up.
- 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 turned = |mut stream: Stream| {
- if turn != 0 {
- 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()));
- }
- stream
- };
- let [x0, y0, x1, y1] = draw;
- let (w, h) = (x1 - x0, y1 - y0);
- let lw = w.min(h) * 0.12;
- match name {
- b"PCCheck" => c.push_str(&format!(
- "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
- rg_stroke(col),
- n(lw),
- n(x0 + w * 0.15),
- n(y0 + h * 0.5),
- n(x0 + w * 0.4),
- n(y0 + h * 0.2),
- n(x0 + w * 0.88),
- n(y0 + h * 0.85)
- )),
- b"PCCross" => c.push_str(&format!(
- "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n",
- rg_stroke(col),
- n(lw),
- n(x0 + w * 0.18),
- n(y0 + h * 0.18),
- n(x1 - w * 0.18),
- n(y1 - h * 0.18),
- n(x0 + w * 0.18),
- n(y1 - h * 0.18),
- n(x1 - w * 0.18),
- n(y0 + h * 0.18)
- )),
- b"PCDot" => {
- let r = w.min(h) * 0.3;
- c.push_str(&rg(col));
- c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r));
- c.push_str("f\n");
- }
- b"PCLine" => c.push_str(&format!(
- "{}{} w 1 J\n{} {} m {} {} l S\n",
- rg_stroke(col),
- n(h.clamp(0.5, 2.0)),
- n(x0),
- n(y0 + h / 2.0),
- n(x1),
- n(y0 + h / 2.0)
- )),
- other => {
- // Only stamps PdfCraft made: others keep their own artwork.
- if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) {
- return None;
- }
- let kind = crate::StampKind::from_name(other)?;
- let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
- let sign_here = kind.group() == crate::StampGroup::SignHere;
- return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
- }
- }
- if turn != 0 {
- return Some(turned(form(draw, c.as_bytes(), res)));
- }
- }
- b"FreeText" => {
- let (text_color, size) = parse_da(d);
- let bg = stroke;
- let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
- // A callout: the leader line (arrowhead at its first point), and the text box inside
- // `/Rect` by `/RD`.
- // Text added to a turned page is drawn upright in its displayed box, which the form
- // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes.
- let turn = if d.contains(b"CL") { 0 } else { text_turn(d) };
- let (rect, matrix) = if turn == 0 {
- (rect, None)
- } 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], Some(pdfcraft_model::view_matrix_for(turn, rect)))
- };
- let full = rect;
- let mut rect = rect;
- if d.contains(b"CL") {
- let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?;
- let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?;
- rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]];
- if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
- return None;
- }
- let end = match d.get(b"LE") {
- None => b"None".to_vec(),
- Some(o) => o.as_name()?.to_vec(),
- };
- if !known_ending(&end) {
- return None;
- }
- let lw = bw.max(0.5);
- let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
- if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
- return None;
- }
- c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
- for (i, p) in pts.iter().enumerate() {
- c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
- }
- c.push_str("S\n");
- if !line_end(&mut c, &end, pts[0], pts[1], lw) {
- return None;
- }
- }
- if let Some(bg) = bg {
- c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
- }
- if bw > 0.0 {
- let h = bw / 2.0;
- c.push_str(&format!(
- "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n",
- rg_stroke(text_color),
- n(bw),
- dash(d),
- n(rect[0] + h),
- n(rect[1] + h),
- n(rect[2] - rect[0] - bw),
- n(rect[3] - rect[1] - bw)
- ));
- }
- let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
- let pad = 2.0 + bw;
- let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
- let q = d.int(b"Q").unwrap_or(0);
- c.push_str(&format!(
- "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
- n(rect[0]),
- n(rect[1]),
- n(rect[2] - rect[0]),
- n(rect[3] - rect[1]),
- n(size),
- rg(text_color)
- ));
- let mut out = c.into_bytes();
- let mut y = rect[3] - pad - size * 0.9;
- for line in wrap(&text, size, width) {
- if y < rect[1] - size {
- break;
- }
- let lw = text_width(&line, size);
- let x = match q {
- 1 => rect[0] + pad + (width - lw) / 2.0,
- 2 => rect[2] - pad - lw,
- _ => rect[0] + pad,
- };
- out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
- out.extend(literal(&win_ansi(&line)));
- out.extend_from_slice(b" Tj\n");
- y -= size * 1.2;
- }
- out.extend_from_slice(b"ET\n");
- res.set(b"Font".to_vec(), helvetica_resource());
- let mut stream = form(full, &out, res);
- if let Some(m) = matrix {
- stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
- }
- return Some(stream);
- }
- _ => return None,
+ Some(AppearanceContext {
+ dict: d,
+ subtype,
+ rect,
+ cloudy,
+ opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0),
+ stroke: color(d, b"C")?,
+ width: border_width(d),
+ })
+}
+
+fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ match context.subtype {
+ b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing),
+ b"Redact" => draw_redaction(context, drawing),
+ b"Square" | b"Circle" => draw_box(context, drawing),
+ b"Line" => draw_line(context, drawing),
+ b"Polygon" | b"PolyLine" => draw_polygon(context, drawing),
+ b"Caret" => draw_caret(context, drawing),
+ b"Ink" => draw_ink(context, drawing),
+ _ => None,
}
+}
+
+fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option {
// PdfCraft measurement captions are kept separate from the comment's free-form text.
// A restyle regenerates the path and its value together.
- let mut out = c.into_bytes();
- out.extend(tail);
- if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) {
+ let mut out = drawing.content.into_bytes();
+ out.extend(drawing.tail);
+ if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) {
let value = value.to_text();
- if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") {
+ if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") {
let size = 10.0;
- let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5;
- let y = rect[3] - 12.0;
- let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
+ let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5;
+ let y = context.rect[3] - 12.0;
+ let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]);
out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes());
// WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8.
out.extend(literal(&win_ansi(&value)));
out.extend_from_slice(b" Tj ET\n");
- res.set(b"Font".to_vec(), helvetica_resource());
+ drawing.resources.set(b"Font".to_vec(), helvetica_resource());
}
}
- Some(form(rect, &out, res))
+ Some(form(context.rect, &out, drawing.resources))
+}
+
+fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ let d = context.dict;
+ let subtype = context.subtype;
+ let stroke = context.stroke;
+ let c = &mut drawing.content;
+ let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
+ let col = stroke?;
+ for quad in q.as_chunks::<8>().0.iter() {
+ let p = |i: usize| (quad[2 * i], quad[2 * i + 1]);
+ let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3));
+ let h = (p1.0 - p3.0).hypot(p1.1 - p3.1);
+ // A point a fraction `t` of the way from the bottom edge to the top edge.
+ let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t);
+ match subtype {
+ b"Highlight" => {
+ c.push_str(&rg(col));
+ c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1)));
+ }
+ b"Underline" | b"StrikeOut" => {
+ let lw = (h * 0.07).clamp(0.5, 3.0);
+ let t = if subtype == b"Underline" { 0.08 } else { 0.42 };
+ let (a, b) = (at(p3, p1, t), at(p4, p2, t));
+ c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1)));
+ }
+ _ => {
+ // Squiggly: a zigzag along the bottom of the quad.
+ let lw = (h * 0.05).clamp(0.5, 2.0);
+ let amp = (h * 0.06).max(0.75);
+ let len = (p4.0 - p3.0).hypot(p4.1 - p3.1);
+ let step = (h / 4.0).max(1.5);
+ let steps = ((len / step).ceil() as usize).clamp(1, 10_000);
+ c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw)));
+ for i in 0..=steps {
+ let t = i as f64 / steps as f64;
+ let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t);
+ let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 };
+ let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) };
+ let (x, y) = (base.0 + ux * up, base.1 + uy * up);
+ c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" }));
+ }
+ c.push_str("S\n");
+ }
+ }
+ }
+ Some(())
+}
+
+fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ let d = context.dict;
+ let stroke = context.stroke;
+ let c = &mut drawing.content;
+ // While marked: the outline of each area (the fill comes when applied).
+ let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
+ let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
+ c.push_str(&format!("{}1 w\n", rg_stroke(col)));
+ for quad in q.as_chunks::<8>().0.iter() {
+ c.push_str(&format!(
+ "{} {} m {} {} l {} {} l {} {} l h S\n",
+ n(quad[0]),
+ n(quad[1]),
+ n(quad[2]),
+ n(quad[3]),
+ n(quad[6]),
+ n(quad[7]),
+ n(quad[4]),
+ n(quad[5])
+ ));
+ }
+ Some(())
+}
+
+fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ let d = context.dict;
+ let subtype = context.subtype;
+ let rect = context.rect;
+ let stroke = context.stroke;
+ let w = context.width;
+ let c = &mut drawing.content;
+ let fill = interior_color(d, None);
+ if stroke.is_none() && fill.is_none() {
+ return Some(());
+ }
+ let inset = w / 2.0;
+ let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset];
+ if let Some(f) = fill {
+ c.push_str(&rg(f));
+ }
+ if let Some(s) = stroke {
+ c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d)));
+ }
+ if subtype == b"Square" {
+ c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0)));
+ } else {
+ c.push_str(&ellipse(x0, y0, x1, y1));
+ }
+ c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) {
+ (true, true) => "B\n",
+ (true, false) => "f\n",
+ (false, true) => "S\n",
+ (false, false) => "n\n",
+ });
+ Some(())
+}
+
+fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ let d = context.dict;
+ let stroke = context.stroke;
+ let w = context.width;
+ let c = &mut drawing.content;
+ let res = &mut drawing.resources;
+ let tail = &mut drawing.tail;
+ let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?;
+ let col = stroke?;
+ let ends: Vec> = match d.get(b"LE") {
+ None => vec![b"None".to_vec(), b"None".to_vec()],
+ Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?,
+ };
+ if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) {
+ return None;
+ }
+ let fill = interior_color(d, Some(col)).unwrap_or(col);
+ let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w);
+ let [p, q] = layout.line;
+ c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
+ for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) {
+ c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1)));
+ }
+ c.push_str("[] 0 d\n");
+ for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) {
+ if !line_end(c, end, tip, from, w) {
+ return None;
+ }
+ }
+ if let Some((text, m)) = layout.caption {
+ // The caption is black, in the standard font the measurement label uses.
+ tail.extend(
+ format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(),
+ );
+ // WinAnsi bytes go into the stream as-is, not through UTF-8.
+ tail.extend(literal(&win_ansi(&text)));
+ tail.extend_from_slice(b" Tj ET Q\n");
+ res.set(b"Font".to_vec(), helvetica_resource());
+ }
+ Some(())
+}
+
+fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ let d = context.dict;
+ let subtype = context.subtype;
+ let stroke = context.stroke;
+ let w = context.width;
+ let cloudy = context.cloudy;
+ let c = &mut drawing.content;
+ let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?;
+ let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
+ let col = stroke;
+ let closed = subtype == b"Polygon";
+ let fill = if closed { interior_color(d, None) } else { None };
+ let ends = if subtype == b"PolyLine" {
+ match d.get(b"LE") {
+ None => None,
+ Some(o) => {
+ let names: Vec> =
+ o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?;
+ if names.iter().any(|e| !known_ending(e)) {
+ return None;
+ }
+ Some(names)
+ }
+ }
+ } else {
+ None
+ };
+ if let Some(f) = fill {
+ c.push_str(&rg(f));
+ }
+ if let Some(s) = col {
+ c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d)));
+ }
+ if cloudy {
+ let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0);
+ c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0)));
+ } else {
+ for (i, p) in pts.iter().enumerate() {
+ c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
+ }
+ if closed {
+ c.push_str("h\n");
+ }
+ }
+ c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) {
+ (true, true) => "B\n",
+ (true, false) => "f\n",
+ (false, true) => "S\n",
+ (false, false) => "n\n",
+ });
+ if let Some(ends) = ends {
+ let col = col?;
+ let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None };
+ let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None };
+ let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None };
+ let fill = interior_color(d, Some(col)).unwrap_or(col);
+ c.push_str(&format!("[] 0 d\n{}", rg(fill)));
+ if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) {
+ return None;
+ }
+ }
+ Some(())
+}
+
+fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ let d = context.dict;
+ let rect = context.rect;
+ let stroke = context.stroke;
+ let c = &mut drawing.content;
+ // A filled caret: two curved flanks meeting at the top centre.
+ let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
+ let (caret, pilcrow) = caret_layout(d, rect);
+ let [x0, y0, x1, y1] = caret;
+ let (cx, h) = ((x0 + x1) / 2.0, y1 - y0);
+ c.push_str(&format!(
+ "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n",
+ rg(col),
+ n(x0),
+ n(y0),
+ n(cx - (x1 - x0) * 0.1),
+ n(y0 + h * 0.2),
+ n(cx),
+ n(y1 - h * 0.25),
+ n(cx),
+ n(y1),
+ n(cx),
+ n(y1 - h * 0.25),
+ n(cx + (x1 - x0) * 0.1),
+ n(y0 + h * 0.2),
+ n(x1),
+ n(y0)
+ ));
+ if let Some((x, y, size)) = pilcrow {
+ // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour.
+ // 0xB6 is the pilcrow in WinAnsiEncoding.
+ drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes());
+ drawing.tail.push(0xB6);
+ drawing.tail.extend_from_slice(b") Tj ET\n");
+ drawing.resources.set(b"Font".to_vec(), helvetica_resource());
+ }
+ Some(())
+}
+
+fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
+ let d = context.dict;
+ let stroke = context.stroke;
+ let w = context.width;
+ let c = &mut drawing.content;
+ let col = stroke?;
+ let list = d.get(b"InkList")?.as_array()?;
+ // `/BS` gives an ink stroke its width and dash pattern (Table 182).
+ c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d)));
+ for s in list {
+ let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?;
+ let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
+ let Some(first) = pts.first() else { continue };
+ c.push_str(&format!("{} {} m\n", n(first.0), n(first.1)));
+ match pts.as_slice() {
+ [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))),
+ [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))),
+ _ => c.push_str(&smooth_curve(&pts)),
+ }
+ c.push_str("S\n");
+ }
+ Some(())
+}
+
+fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option {
+ let d = context.dict;
+ let rect = context.rect;
+ let stroke = context.stroke;
+ let opacity = context.opacity;
+ let c = &mut drawing.content;
+ let mut res = std::mem::take(&mut drawing.resources);
+ // A typed signature: filled outlines normalised to the rectangle.
+ if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) {
+ let [x0, y0, x1, y1] = rect;
+ let (w, h) = (x1 - x0, y1 - y0);
+ c.push_str(&rg(stroke.unwrap_or([0.0; 3])));
+ for contour in outline {
+ let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
+ for (i, p) in v.as_chunks::<2>().0.iter().enumerate() {
+ c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" }));
+ }
+ if v.len() >= 6 {
+ c.push_str("h\n");
+ }
+ }
+ c.push_str("f\n");
+ return Some(form(rect, c.as_bytes(), res));
+ }
+ // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
+ // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets
+ // the standard counter-rotation as its form `/Matrix`: viewers fit the turned
+ // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
+ if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
+ let [x0, y0, x1, y1] = rect;
+ let (w, h) = (x1 - x0, y1 - y0);
+ let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
+ let place = if image {
+ format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
+ } else {
+ let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
+ format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0))
+ };
+ c.push_str(&format!("q {place} /Pic Do Q\n"));
+ let mut xo = Dict::new();
+ xo.set(b"Pic".to_vec(), Object::Ref(pic));
+ res.set(b"XObject".to_vec(), Object::Dict(xo));
+ let mut stream = form(rect, c.as_bytes(), res);
+ let turn = picture_turn(d);
+ if image && turn != 0 {
+ // The page's view matrix without its offset: display axes in user space.
+ let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
+ let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0];
+ stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect()));
+ }
+ return Some(stream);
+ }
+ // Only PdfCraft's own Fill & Sign marks are drawn here.
+ let name = d.name(b"Name")?;
+ let col = stroke.unwrap_or([0.0; 3]);
+ // A mark or stamp placed on a turned page is drawn upright in its 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 it the right way up.
+ 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 turned = |mut stream: Stream| {
+ if turn != 0 {
+ 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()));
+ }
+ stream
+ };
+ let [x0, y0, x1, y1] = draw;
+ let (w, h) = (x1 - x0, y1 - y0);
+ let lw = w.min(h) * 0.12;
+ match name {
+ b"PCCheck" => c.push_str(&format!(
+ "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
+ rg_stroke(col),
+ n(lw),
+ n(x0 + w * 0.15),
+ n(y0 + h * 0.5),
+ n(x0 + w * 0.4),
+ n(y0 + h * 0.2),
+ n(x0 + w * 0.88),
+ n(y0 + h * 0.85)
+ )),
+ b"PCCross" => c.push_str(&format!(
+ "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n",
+ rg_stroke(col),
+ n(lw),
+ n(x0 + w * 0.18),
+ n(y0 + h * 0.18),
+ n(x1 - w * 0.18),
+ n(y1 - h * 0.18),
+ n(x0 + w * 0.18),
+ n(y1 - h * 0.18),
+ n(x1 - w * 0.18),
+ n(y0 + h * 0.18)
+ )),
+ b"PCDot" => {
+ let r = w.min(h) * 0.3;
+ c.push_str(&rg(col));
+ c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r));
+ c.push_str("f\n");
+ }
+ b"PCLine" => c.push_str(&format!(
+ "{}{} w 1 J\n{} {} m {} {} l S\n",
+ rg_stroke(col),
+ n(h.clamp(0.5, 2.0)),
+ n(x0),
+ n(y0 + h / 2.0),
+ n(x1),
+ n(y0 + h / 2.0)
+ )),
+ other => {
+ // Only stamps PdfCraft made: others keep their own artwork.
+ if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) {
+ return None;
+ }
+ let kind = crate::StampKind::from_name(other)?;
+ let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
+ let sign_here = kind.group() == crate::StampGroup::SignHere;
+ return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
+ }
+ }
+ if turn != 0 {
+ return Some(turned(form(draw, c.as_bytes(), res)));
+ }
+ Some(form(rect, c.as_bytes(), res))
+}
+
+fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option {
+ let d = context.dict;
+ let rect = context.rect;
+ let stroke = context.stroke;
+ let mut c = std::mem::take(&mut drawing.content);
+ let mut res = std::mem::take(&mut drawing.resources);
+ let _tail = std::mem::take(&mut drawing.tail);
+ let (text_color, size) = parse_da(d);
+ let bg = stroke;
+ let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
+ // A callout: the leader line (arrowhead at its first point), and the text box inside
+ // `/Rect` by `/RD`.
+ // Text added to a turned page is drawn upright in its displayed box, which the form
+ // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes.
+ let turn = if d.contains(b"CL") { 0 } else { text_turn(d) };
+ let (rect, matrix) = if turn == 0 {
+ (rect, None)
+ } 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], Some(pdfcraft_model::view_matrix_for(turn, rect)))
+ };
+ let full = rect;
+ let mut rect = rect;
+ if d.contains(b"CL") {
+ let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?;
+ let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?;
+ rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]];
+ if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
+ return None;
+ }
+ let end = match d.get(b"LE") {
+ None => b"None".to_vec(),
+ Some(o) => o.as_name()?.to_vec(),
+ };
+ if !known_ending(&end) {
+ return None;
+ }
+ let lw = bw.max(0.5);
+ let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
+ if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
+ return None;
+ }
+ c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
+ for (i, p) in pts.iter().enumerate() {
+ c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
+ }
+ c.push_str("S\n");
+ if !line_end(&mut c, &end, pts[0], pts[1], lw) {
+ return None;
+ }
+ }
+ if let Some(bg) = bg {
+ c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
+ }
+ if bw > 0.0 {
+ let h = bw / 2.0;
+ c.push_str(&format!(
+ "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n",
+ rg_stroke(text_color),
+ n(bw),
+ dash(d),
+ n(rect[0] + h),
+ n(rect[1] + h),
+ n(rect[2] - rect[0] - bw),
+ n(rect[3] - rect[1] - bw)
+ ));
+ }
+ let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
+ let pad = 2.0 + bw;
+ let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
+ let q = d.int(b"Q").unwrap_or(0);
+ c.push_str(&format!(
+ "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
+ n(rect[0]),
+ n(rect[1]),
+ n(rect[2] - rect[0]),
+ n(rect[3] - rect[1]),
+ n(size),
+ rg(text_color)
+ ));
+ let mut out = c.into_bytes();
+ let mut y = rect[3] - pad - size * 0.9;
+ for line in wrap(&text, size, width) {
+ if y < rect[1] - size {
+ break;
+ }
+ let lw = text_width(&line, size);
+ let x = match q {
+ 1 => rect[0] + pad + (width - lw) / 2.0,
+ 2 => rect[2] - pad - lw,
+ _ => rect[0] + pad,
+ };
+ out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
+ out.extend(literal(&win_ansi(&line)));
+ out.extend_from_slice(b" Tj\n");
+ y -= size * 1.2;
+ }
+ out.extend_from_slice(b"ET\n");
+ res.set(b"Font".to_vec(), helvetica_resource());
+ let mut stream = form(full, &out, res);
+ if let Some(m) = matrix {
+ stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
+ }
+ Some(stream)
+}
+
+fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option {
+ let d = context.dict;
+ let col = context.stroke.unwrap_or(fallback);
+ let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into());
+ let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) };
+ drawing.content.push_str(&content);
+ Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources))
}
diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs
index d49224cd9f..3b33662524 100644
--- a/crates/annot/src/appearance/symbols.rs
+++ b/crates/annot/src/appearance/symbols.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs
index bedd1c6f15..a6db5b940b 100644
--- a/crates/annot/src/create.rs
+++ b/crates/annot/src/create.rs
@@ -1,8 +1,10 @@
use super::draw::draw_appearance;
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
use super::modify::{n, rich_text};
-use super::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style};
-use super::*;
+use super::types::{
+ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
+};
+use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
pub(crate) fn num_array(v: &[f64]) -> Object {
Object::Array(v.iter().map(|x| Object::Real(*x)).collect())
@@ -203,16 +205,12 @@ pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result {
let page = page_ref(doc, new.page)?;
let style = &new.style;
- if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
- return Err(AnnotError::Invalid("invalid style".into()));
- }
+ validate_style(style)?;
let rect = rect_for(&new.shape, style)?;
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
let opacity = style.opacity.clamp(0.0, 1.0);
- if opacity < 1.0 {
- d.set(b"CA".to_vec(), Object::Real(opacity));
- }
+ set_non_opaque_style(&mut d, opacity);
let border = |d: &mut Dict| {
let mut bs = Dict::new();
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
@@ -232,24 +230,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
}
- Shape::Redact { quads, overlay, look } => {
- d.set(b"C".to_vec(), rgb(style.color));
- d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
- d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
- if !overlay.is_empty() {
- d.set(b"OverlayText".to_vec(), PdfString::text(overlay));
- let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
- let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
- d.set(
- b"DA".to_vec(),
- PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()),
- );
- d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
- if look.repeat {
- d.set(b"Repeat".to_vec(), Object::Bool(true));
- }
- }
- }
+ Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look),
Shape::Rectangle { .. } | Shape::Oval { .. } => {
d.set(b"C".to_vec(), rgb(style.color));
if let Some(f) = style.fill {
@@ -426,22 +407,55 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
- if let Some(pr) = popup {
- let mut p = Dict::new();
- p.set(b"Type".to_vec(), Object::name("Annot"));
- p.set(b"Subtype".to_vec(), Object::name("Popup"));
- p.set(b"Rect".to_vec(), num_array(&pr));
- p.set(b"Parent".to_vec(), Object::Ref(r));
- p.set(b"Open".to_vec(), Object::Bool(false));
- p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
- let pref = doc.add(Object::Dict(p));
- doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
- list.push(Object::Ref(pref));
- }
+ add_popup(doc, r, popup, &mut list)?;
set_annots(doc, page, list)?;
Ok(index)
}
+fn validate_style(style: &Style) -> Result<(), AnnotError> {
+ if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
+ return Err(AnnotError::Invalid("invalid style".into()));
+ }
+ Ok(())
+}
+
+fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) {
+ dict.set(b"C".to_vec(), rgb(style.color));
+ dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
+ dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
+ if !overlay.is_empty() {
+ dict.set(b"OverlayText".to_vec(), PdfString::text(overlay));
+ let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
+ let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
+ dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()));
+ dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
+ if look.repeat {
+ dict.set(b"Repeat".to_vec(), Object::Bool(true));
+ }
+ }
+}
+
+fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
+ if opacity < 1.0 {
+ dict.set(b"CA".to_vec(), Object::Real(opacity));
+ }
+}
+
+fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> {
+ let Some(rect) = rect else { return Ok(()) };
+ let mut popup = Dict::new();
+ popup.set(b"Type".to_vec(), Object::name("Annot"));
+ popup.set(b"Subtype".to_vec(), Object::name("Popup"));
+ popup.set(b"Rect".to_vec(), num_array(&rect));
+ popup.set(b"Parent".to_vec(), Object::Ref(parent));
+ popup.set(b"Open".to_vec(), Object::Bool(false));
+ popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
+ let popup_ref = doc.add(Object::Dict(popup));
+ doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?;
+ annots.push(Object::Ref(popup_ref));
+ Ok(())
+}
+
/// 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.
diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs
index 1898454415..5e698332da 100644
--- a/crates/annot/src/draw.rs
+++ b/crates/annot/src/draw.rs
@@ -2,7 +2,7 @@ use super::create::{finite, num_array};
use super::lookup::{annot_dict, annots, page_refs};
use super::modify::refuse_foreign_text;
use super::types::NOTE_SIZE;
-use super::*;
+use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance};
/// (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).
diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs
index bea14d9ee2..b56e0de20d 100644
--- a/crates/annot/src/info.rs
+++ b/crates/annot/src/info.rs
@@ -3,7 +3,7 @@ use super::draw::set_appearance;
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
use super::modify::{fillable, touch, unlocked};
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
-use super::*;
+use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
// ── reading ─────────────────────────────────────────────────────────────────────────────────
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index 1d85d0bd64..7bd0894c66 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -33,15 +33,17 @@ pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries};
#[cfg(test)]
use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref};
use lookup::{annots, page_ref};
-pub use lookup::{has_visible_fill_sign, is_fill_sign, isolate_annotation, page_refs, picture_rotation, set_annots, signature_image};
+pub use lookup::{
+ has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image,
+};
pub(crate) use modify::n;
pub use modify::{
- add_reply, add_text_replacement, delete_annotation, erase_ink, fillable, goes_with, move_annotation, set_contents, set_fill, set_font_size,
- set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
+ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
+ set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
};
pub use types::{
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
- StampGroup, StampKind, Style, rect_quad,
+ StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
};
pub mod appearance;
diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs
index 00ce23c37f..c7a0fd9831 100644
--- a/crates/annot/src/lookup.rs
+++ b/crates/annot/src/lookup.rs
@@ -1,6 +1,6 @@
use super::info::text_value;
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
-use super::*;
+use super::{AnnotError, Dict, Document, ObjRef, Object, appearance};
// ── pages and /Annots ───────────────────────────────────────────────────────────────────────
@@ -226,16 +226,33 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result Result {
+ isolate_annotations(doc, page, &[index])
+}
+
+/// [`isolate_annotation`] for several annotations of one page: those of them with a normal
+/// appearance stay, in their order on the page. `false`, with `doc` unchanged, when none has one.
+/// An index that isn't one of the page's annotations is an error even when the others are, and
+/// leaves `doc` unchanged too.
+pub fn isolate_annotations(doc: &mut Document, page: usize, indices: &[usize]) -> Result {
let p = page_ref(doc, page)?;
- let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
- let has_normal = {
- let obj = doc.resolve(&entry);
- obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")))
- };
- if !has_normal {
+ let list = annots(doc, p);
+ let mut kept = Vec::new();
+ for (index, entry) in list.iter().enumerate() {
+ if !indices.contains(&index) {
+ continue;
+ }
+ let obj = doc.resolve(entry);
+ if obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) {
+ kept.push(entry.clone());
+ }
+ }
+ if let Some(&index) = indices.iter().find(|&&i| i >= list.len()) {
+ return Err(AnnotError::NoSuchAnnotation { page, index });
+ }
+ if kept.is_empty() {
return Ok(false);
}
- set_annots(doc, p, vec![entry])?;
+ set_annots(doc, p, kept)?;
doc.update_dict(p, |d| {
d.remove(b"Contents");
})?;
diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs
index 992b3d0c51..2e3ae0b7d0 100644
--- a/crates/annot/src/modify.rs
+++ b/crates/annot/src/modify.rs
@@ -6,8 +6,9 @@ use super::info::{line_endings_of, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
use super::types::{
FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style,
+ StyleChange, TextReplacement,
};
-use super::*;
+use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
/// 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.
@@ -69,20 +70,53 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
}
/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does.
+/// Refused while it, or a reply that would go with it, is locked.
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
- let p = page_ref(doc, page)?;
- let list = annots(doc, p);
- let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
+ deletable(doc, page, index)?;
+ lift_annotation(doc, page, index)
+}
+
+/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without
+/// changing anything: what a Delete command asks before it is offered. It is refused while the
+/// annotation is locked, or a reply that would go with it is.
+pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> {
+ let list = annots(doc, page_ref(doc, page)?);
+ let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
if let Some(r) = target.as_ref() {
unlocked(doc, r)?;
}
- // An inline comment has no reference to collect; the pop-up it names still goes.
- let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")));
- let doomed = with_replies_and_popups(doc, &list, seed);
- let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect();
+ let doomed = with_replies_and_popups(doc, &list, seed(target));
+ // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
+ // answers either. Pop-ups follow their comment.
+ for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
+ let obj = doc.resolve(e);
+ if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice()))
+ && annotation_flags(doc, d) & FLAG_LOCKED != 0
+ {
+ return Err(AnnotError::Invalid("a reply to it is locked".into()));
+ }
+ }
+ Ok(())
+}
+
+/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as
+/// [`delete_annotation`] does but whatever their locks: for drawing the page beneath it on a
+/// copy of the document (a stamp or signature being dragged), which changes nothing.
+pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
+ let p = page_ref(doc, page)?;
+ let list = annots(doc, p);
+ let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
+ let doomed = with_replies_and_popups(doc, &list, seed(target));
+ let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
Ok(set_annots(doc, p, kept)?)
}
+/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
+/// comment (which has none to collect) the pop-up it names, which still goes.
+fn seed(target: &Object) -> Option {
+ target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")))
+}
+
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
/// one of them, or it is written inline (a file can, though the standard asks for references) and
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
@@ -499,17 +533,8 @@ fn border_width_of(d: &Dict) -> f64 {
}
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
-#[allow(clippy::too_many_arguments)]
-pub fn set_style(
- doc: &mut Document,
- page: usize,
- index: usize,
- color: Option,
- opacity: Option,
- width: Option,
- endings: Option<&[LineEnding]>,
- meta: &Meta,
-) -> Result<(), AnnotError> {
+pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
+ let StyleChange { color, opacity, width, endings } = change;
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
@@ -521,7 +546,7 @@ pub fn set_style(
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
return Err(AnnotError::Invalid("invalid style".into()));
}
- if let Some(ends) = endings {
+ if let Some(ends) = &endings {
let callout = subtype == "FreeText" && d.contains(b"CL");
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
@@ -691,18 +716,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> {
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
/// from the caller, so the edit stays deterministic.
-#[allow(clippy::too_many_arguments)]
-pub fn add_text_replacement(
- doc: &mut Document,
- page: usize,
- quads: &[[f64; 8]],
- replacement: &str,
- author: &str,
- strike: &Style,
- caret: &Style,
- meta: &Meta,
- caret_meta: &Meta,
-) -> Result {
+pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result {
+ let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement;
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
let s = add_annotation(
doc,
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index b9e47e5fb9..a3dc14e262 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -229,6 +229,34 @@ fn delete_takes_popup_and_reply_chain() {
assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 }));
}
+/// A locked reply can't be deleted, so it doesn't go with what it answers either: deleting the
+/// reply above it, or the comment, is refused and changes nothing; once it is unlocked they go.
+#[test]
+fn a_locked_reply_keeps_what_it_answers() {
+ let mut doc = fixture();
+ let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
+ let reply = add_reply(&mut doc, 0, note, "first", "Bob", &meta("r1")).unwrap();
+ let answer = add_reply(&mut doc, 0, reply, "locked answer", "Ada", &meta("r2")).unwrap();
+ set_locked(&mut doc, 0, answer, true).unwrap();
+ let before = list(&doc, 0).len();
+ assert_eq!(delete_annotation(&mut doc, 0, reply), Err(AnnotError::Invalid("a reply to it is locked".into())));
+ assert_eq!(delete_annotation(&mut doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into())));
+ assert_eq!(list(&doc, 0).len(), before, "nothing went");
+ // Asked first, read-only, as a Delete command does: the same answer, and the answer's own
+ // lock is its own.
+ assert_eq!(deletable(&doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into())));
+ assert_eq!(deletable(&doc, 0, answer), Err(AnnotError::Invalid("the comment is locked".into())));
+ // Lifted on a copy, to draw the page beneath, the locks don't count.
+ let mut copy = doc.clone();
+ lift_annotation(&mut copy, 0, note).unwrap();
+ assert!(list(©, 0).len() < before, "the thread is lifted off the copy");
+ set_locked(&mut doc, 0, answer, false).unwrap();
+ assert_eq!(deletable(&doc, 0, reply), Ok(()));
+ delete_annotation(&mut doc, 0, reply).unwrap();
+ let left: Vec = list(&doc, 0).iter().map(|d| text(d, b"Contents")).collect();
+ assert!(!left.iter().any(|t| t == "first" || t == "locked answer"), "the reply and its answer went: {left:?}");
+}
+
/// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for
/// references) goes with the comment it points at, and so does a pop-up an inline reply or an
/// inline comment names. An unrelated inline comment stays until it is deleted itself.
@@ -303,7 +331,7 @@ fn move_shifts_all_geometry_and_the_popup() {
fn restyle_and_resize_redraw_the_appearance() {
let mut doc = fixture();
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
- set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap();
+ set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap();
set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
@@ -313,7 +341,10 @@ fn restyle_and_resize_redraw_the_appearance() {
assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5));
// A stamp's appearance can't be regenerated, so restyling it changes nothing.
let before = list(&doc, 1)[0].clone();
- assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
+ assert_eq!(
+ set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")),
+ Err(AnnotError::Unsupported("Stamp".into()))
+ );
assert_eq!(list(&doc, 1)[0], before);
// Lines can't be resized as rectangles.
let l = add_annotation(
@@ -374,7 +405,7 @@ fn text_box_text_and_colour_changes_are_drawn() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap();
set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap();
- set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
+ set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[t];
assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%");
@@ -556,8 +587,8 @@ fn fill_and_sign_items_are_drawn() {
assert_eq!(text(&all[s], b"Subj"), "Signature");
// Other stamps still can't be restyled; ours can.
let mut doc = doc;
- set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
- assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err());
+ set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
+ assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err());
}
#[test]
@@ -844,7 +875,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
- let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap();
+ let i = add_text_replacement(
+ &mut doc,
+ TextReplacement {
+ page: 0,
+ quads: &quads,
+ replacement: "new words",
+ author: "Ada",
+ strike: &red,
+ caret: &blue,
+ strike_meta: &meta("r"),
+ caret_meta: &meta("r-caret"),
+ },
+ )
+ .unwrap();
let doc = reopen(&doc);
let a = list(&doc, 0);
let strike = &a[i];
@@ -866,7 +910,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let mut doc = doc;
delete_annotation(&mut doc, 0, i).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
- assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err());
+ assert!(
+ add_text_replacement(
+ &mut doc,
+ TextReplacement {
+ page: 0,
+ quads: &[],
+ replacement: "x",
+ author: "Ada",
+ strike: &red,
+ caret: &blue,
+ strike_meta: &meta("r"),
+ caret_meta: &meta("r-caret")
+ }
+ )
+ .is_err()
+ );
}
#[test]
@@ -1177,7 +1236,7 @@ fn every_line_ending_is_drawn() {
)
.unwrap();
let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"));
- set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap();
+ set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap();
let mut doc = reopen(&doc);
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
let after = rect(&line);
@@ -1245,7 +1304,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() {
let other_dict = list(&doc, 0)[other].clone();
let source = other_dict.get(b"AP").unwrap();
let before = doc.resolve(source);
- set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
+ set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
assert_eq!(doc.resolve(source), before, "a shared AP must not change");
for full in [false, true] {
let bytes = if full {
@@ -1367,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
.unwrap();
assert_eq!(mark(&doc, s), Some('张'));
// Our own `?` appearance is redrawn on a restyle; Western European text clears the mark.
- set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
+ set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
assert_eq!(mark(&doc, t), Some('签'));
set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap();
assert_eq!(mark(&doc, t), None);
@@ -1395,7 +1454,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
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, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), 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();
@@ -1476,7 +1535,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() {
fn text_box_font_size_changes_the_da_and_grows_the_box() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
- set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
+ set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap();
let height = |doc: &Document| {
let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
@@ -1522,7 +1581,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() {
let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
assert!(props(&doc, 0, index).unwrap().fillable);
set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
- set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
+ set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap();
let saved = reopen(&doc);
let properties = props(&saved, 0, index).unwrap();
assert_eq!(properties.fill, Some([1.0; 3]));
@@ -1791,7 +1850,7 @@ fn a_star_note_keeps_its_star_when_recoloured() {
b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>",
]);
assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star));
- set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
+ set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[0];
assert_eq!(d.name(b"Name"), Some(&b"Star"[..]));
diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs
index 632e990fea..bd3409b5ae 100644
--- a/crates/annot/src/types.rs
+++ b/crates/annot/src/types.rs
@@ -1,5 +1,5 @@
use super::create::normalize;
-use super::*;
+use super::{ObjRef, appearance};
pub type Rgb = [f64; 3];
@@ -563,6 +563,33 @@ pub struct Style {
pub fill: Option,
}
+/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct StyleChange {
+ pub color: Option,
+ pub opacity: Option,
+ pub width: Option,
+ pub endings: Option>,
+}
+
+impl StyleChange {
+ pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self {
+ Self { color, opacity, width, endings }
+ }
+}
+
+/// Inputs for replacing selected text with a grouped strikeout and caret annotation.
+pub struct TextReplacement<'a> {
+ pub page: usize,
+ pub quads: &'a [[f64; 8]],
+ pub replacement: &'a str,
+ pub author: &'a str,
+ pub strike: &'a Style,
+ pub caret: &'a Style,
+ pub strike_meta: &'a Meta,
+ pub caret_meta: &'a Meta,
+}
+
impl Default for Style {
fn default() -> Self {
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
diff --git a/crates/automation/README.md b/crates/automation/README.md
index d17084aa30..176946c175 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -181,6 +181,12 @@ working document unchanged.
`content_list` or `object_list`. It removes only that item in a read-only preview and is
mutually exclusive with `hide_text_block`.
+When the renderer reports a warning for the page, `page_render` and `render_preview` return a
+JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences
+`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour
+that could not be computed was painted in a fallback colour". A page without warnings returns
+the PNG alone.
+
## Existing text and document fonts
@@ -214,6 +220,18 @@ unapplied with a warning.
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.
+## Editing button export values
+
+Use `form_set_props` with `field`, `export_value` and optional `widget`
+(1-based, default 1) to change a checkbox or radio button export value.
+`form_fields` returns `export_values` in widget order. The selected/default
+appearance states are preserved; the operation is undoable and survives saving.
+Widgets sharing an appearance state keep one export value. Bulk edits are not
+supported for this widget-specific property. Empty values, `Off`, conflicting
+choices and values over 4096 UTF-8 bytes return an error.
+
+Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
+
## Viewer navigation history
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
@@ -229,3 +247,10 @@ behavior. Back/Forward
returns to page tops, does not restore zoom, does not switch documents, and is not saved across
restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate
navigation-history tool; use the opt-in UI control channel to navigate the displayed document.
+
+## Fill & Sign text appearance
+
+`fill_sign_add` accepts `font_size` (4–300 pt, default 10) and `color`
+(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the
+FreeText appearance and survives reopening. Other mark/signature types reject
+`color`; their appearance controls are separate.
diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs
index f623ef714e..e52768d535 100644
--- a/crates/automation/src/a11y/documents.rs
+++ b/crates/automation/src/a11y/documents.rs
@@ -1,11 +1,11 @@
-use super::*;
+use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic};
impl Automation {
pub(crate) fn doc_export_office(&self, a: &Args) -> Result {
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, pptx, html, rtf or md)")))?;
let export = self.doc_to_copy(a)?.export_office_checked(format);
write_atomic(&path, &export.bytes)?;
// Text left out to keep a hostile page bounded is said, not silent (#3120).
diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs
index 5fb14f136e..2420a44372 100644
--- a/crates/automation/src/a11y/form_tools.rs
+++ b/crates/automation/src/a11y/form_tools.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic};
/// A script's request to the viewer as the JSON that running a script reports; a side panel
/// goes by its fixed id.
@@ -17,14 +17,21 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
}
}
+/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
+/// icon and buttons.
+fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
+ json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
+}
+
impl Automation {
+ /// `js_run`: run `script` in the document, or as button `field`'s script, and report its
+ /// messages, console lines, requests, error, the failing document scripts' errors and result.
pub(crate) fn js_run(&mut self, a: &Args) -> Result {
let id = self.doc(a)?.id;
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
let reqs: Vec = o.requests.iter().map(request_json).collect();
- Ok(
- json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
- )
+ let alerts: Vec = o.alerts.iter().map(alert_json).collect();
+ Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }))
}
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result {
diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs
index afc671a8c7..82e521ddd5 100644
--- a/crates/automation/src/a11y/ocr.rs
+++ b/crates/automation/src/a11y/ocr.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
impl Automation {
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs
index 40ae65ea87..deb2efa18a 100644
--- a/crates/automation/src/assemble.rs
+++ b/crates/automation/src/assemble.rs
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, one_based, summary};
use super::paths::{child, read_input, read_input_bytes, unused, write_atomic};
-use super::*;
+use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json};
impl Automation {
pub(crate) fn doc_create(&mut self, a: &Args) -> Result {
diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs
index 47cf714ec9..decb18d2b7 100644
--- a/crates/automation/src/batch.rs
+++ b/crates/automation/src/batch.rs
@@ -17,6 +17,7 @@ const MAX_INPUT: u64 = 512 * 1024 * 1024;
struct Recipe<'a> {
steps: Vec<&'a str>,
images: optimize::ImageSettings,
+ mono: optimize::MonoSettings,
ocr: ocr::OcrSettings,
}
@@ -38,12 +39,17 @@ impl<'a> Recipe<'a> {
}
}
let mut images = optimize::ImageSettings::REDUCE;
+ let mut mono = optimize::MonoSettings::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;
+ // Black-and-white images keep Reduce File Size's higher resolution, at which text
+ // stays legible, and are never taken below a higher `ppi` asked for.
+ mono.target_ppi = mono.target_ppi.max(ppi);
+ mono.above_ppi = mono.target_ppi * 1.5;
}
if let Some(q) = args.opt_int("quality")? {
if !(1..=100).contains(&q) {
@@ -54,7 +60,7 @@ impl<'a> Recipe<'a> {
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)? })
+ Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? })
}
fn accepts(&self, path: &Path) -> bool {
@@ -88,7 +94,7 @@ impl<'a> Recipe<'a> {
}
"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 settings = optimize::Settings { color: self.images, gray: self.images, mono: self.mono, ..Default::default() };
let (candidate, _) = session.optimized_bytes(id, &settings, &[])?;
session.close(id);
if candidate.len() < bytes.len() {
diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs
index 0150001096..f87fd0b58e 100644
--- a/crates/automation/src/comments.rs
+++ b/crates/automation/src/comments.rs
@@ -502,6 +502,10 @@ impl Automation {
if sensitivity.is_some() && !scan {
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
}
+ let requested_color = a.color("color")?;
+ if requested_color.is_some() && !matches!(kind, "text" | "date") {
+ return Err(ToolError::InvalidArgs("color is only for type text or date".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()));
@@ -579,7 +583,10 @@ impl Automation {
(Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new())
}
};
- let style = Style::default_for(&shape);
+ let mut style = Style::default_for(&shape);
+ if let Some(color) = requested_color {
+ style.color = color;
+ }
self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author }))
}
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
index c860298d98..7518072d1c 100644
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -53,13 +53,13 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
t.color = parse_color(c)?;
}
if let Some(al) = a.opt_str("align")? {
- t.align = match al {
- "left" => TextAlign::Left,
- "center" | "centre" => TextAlign::Center,
- "right" => TextAlign::Right,
- "justify" => TextAlign::Justify,
- other => return Err(bad(format!("unknown align {other:?}"))),
- };
+ t.align = parse_align(al)?;
+ // The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one.
+ t.aligns.clear();
+ }
+ if let Some(aligns) = opt_aligns(a)? {
+ let count = pdfcraft_engine::paragraph_align::count(&t.text);
+ t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align);
}
if let Some(c) = a.opt_num("char_spacing")? {
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
@@ -70,6 +70,37 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
Ok(())
}
+pub(crate) fn parse_align(al: &str) -> Result {
+ Ok(match al {
+ "left" => TextAlign::Left,
+ "center" | "centre" => TextAlign::Center,
+ "right" => TextAlign::Right,
+ "justify" => TextAlign::Justify,
+ other => return Err(bad(format!("unknown align {other:?}"))),
+ })
+}
+
+/// Most entries `aligns` takes: one per paragraph, and no text has this many.
+const MAX_ALIGNS: usize = 4096;
+
+/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`.
+pub(crate) fn opt_aligns(a: &Args) -> Result>>> {
+ let Some(v) = a.get("aligns") else { return Ok(None) };
+ let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
+ if items.len() > MAX_ALIGNS {
+ return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
+ }
+ items
+ .iter()
+ .map(|v| match v {
+ Value::Null => Ok(None),
+ Value::String(s) => parse_align(s).map(Some),
+ _ => Err(bad("each entry of aligns is left, center, right, justify or null")),
+ })
+ .collect::>>()
+ .map(Some)
+}
+
impl Automation {
pub(crate) fn content_list(&self, a: &Args) -> Result {
let doc = self.doc(a)?;
@@ -160,6 +191,8 @@ impl Automation {
match &mut content {
AddedContent::Text(t) => {
if let Some(s) = a.opt_str("text")? {
+ // Each paragraph keeps its own alignment through the retyping.
+ t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns);
t.text = s.to_string();
}
style(a, t)?;
@@ -171,8 +204,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
- if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
- return Err(bad("font, size, bold, italic, color, align and char_spacing apply to text"));
+ if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
+ return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs
index ac670109fb..a84c6488a4 100644
--- a/crates/automation/src/conventions.rs
+++ b/crates/automation/src/conventions.rs
@@ -1,7 +1,7 @@
//! Shared command/inspection/preview entry points, using the existing confined tool path.
use serde_json::{Value, json};
-use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools};
+use crate::{Args, Automation, Content, Result, ToolError, failed, info, rendered_content, tools};
use pdfcraft_engine::commands;
use pdfcraft_render::{RenderRequest, RequestKind};
@@ -108,7 +108,8 @@ impl Automation {
Ok(json!({"documents":self.session.docs().iter().map(info).collect::>()}))
}
- pub(crate) fn render_preview(&mut self, args: &Args) -> Result {
+ /// A bounded PNG of the page, with its render warnings after it as `page_render` gives them.
+ pub(crate) fn render_preview(&mut self, args: &Args) -> Result> {
let id = if args.opt_int("doc")?.is_some() {
self.doc(args)?.id
} else {
@@ -134,7 +135,6 @@ impl Automation {
if let Some(error) = out.error {
return Err(failed(error));
}
- let data = encode_png(out.width, out.height, &out.rgba)?;
- Ok(Content::Png { data, width: out.width, height: out.height })
+ rendered_content(&out, page)
}
}
diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs
index 26e1f95365..36e0d00454 100644
--- a/crates/automation/src/dispatch.rs
+++ b/crates/automation/src/dispatch.rs
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
-use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
+use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels};
use super::paths::{read_input, write_atomic};
-use super::*;
+use super::{Arc, Automation, Edit, Value, block_overlapping, comments, content, json, tools};
/// One piece of a tool's result.
#[derive(Clone, Debug, PartialEq)]
@@ -38,7 +38,7 @@ impl Automation {
let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() };
self.apply(&a, edit)?
}
- "page_render" => return self.page_render(&a).map(|c| vec![c]),
+ "page_render" => return self.page_render(&a),
"comment_image_preview" => return self.comment_image_preview(&a),
"text_extract" => self.text_extract(&a)?,
"text_word_count" => self.text_word_count(&a)?,
@@ -142,7 +142,7 @@ impl Automation {
"command_run" => return self.command_run(&a),
"command_batch" => self.command_batch(&a)?,
"doc_inspect" => self.doc_inspect(&a)?,
- "render_preview" => return self.render_preview(&a).map(|c| vec![c]),
+ "render_preview" => return self.render_preview(&a),
"page_number" => {
use pdfcraft_organize::LabelStyle as L;
let n = self.doc(&a)?.info.pages.len();
@@ -234,6 +234,34 @@ impl Automation {
out["next"] = listing["next"].clone();
out
}
+ "text_styles" => {
+ // The samples are the document's text, as text_lines is.
+ let doc = self.doc_to_copy(&a)?;
+ let styles: Vec = doc
+ .text_styles()
+ .into_iter()
+ .map(|u| {
+ json!({
+ "font": u.style.font,
+ "size": u.style.size,
+ "bold": u.style.bold,
+ "italic": u.style.italic,
+ "lines": u.lines,
+ "pages": u.pages,
+ "sample": u.sample,
+ })
+ })
+ .collect();
+ json!({ "styles": styles })
+ }
+ "bookmark_from_text_styles" => {
+ let levels = text_style_levels(&a)?;
+ let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?;
+ let listing = self.bookmark_listing(&a)?;
+ out["bookmarks"] = listing["bookmarks"].clone();
+ out["next"] = listing["next"].clone();
+ out
+ }
"doc_protect" => self.doc_protect(&a)?,
"page_replace" => {
let pages = self.pages(&a, "pages")?;
@@ -510,7 +538,15 @@ impl Automation {
};
let index = self.paragraph_index(id, page, k, &blocks)?;
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
- let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
+ let width = a.opt_num("width")?;
+ // Explicit row alignments keep the editor's row boundaries; a width-only
+ // reflow rejoins soft wraps while retaining typed newlines and spaces.
+ let text = match a.opt_str("text")? {
+ Some(text) => text.to_owned(),
+ None if a.get("aligns").is_some() => block.editor_text,
+ None if width.is_some() => block.reflow_text.clone(),
+ None => 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),
@@ -520,7 +556,7 @@ impl Automation {
line_spacing: a.opt_num("line_spacing")?,
char_spacing: a.opt_num("char_spacing")?,
scale: a.opt_num("scale")?,
- width: a.opt_num("width")?,
+ width,
..Default::default()
};
let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?);
@@ -540,13 +576,10 @@ impl Automation {
style.color = Some(comments::parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
- style.align = Some(match al {
- "left" => pdfcraft_engine::TextAlign::Left,
- "center" => pdfcraft_engine::TextAlign::Center,
- "right" => pdfcraft_engine::TextAlign::Right,
- "justify" => pdfcraft_engine::TextAlign::Justify,
- other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
- });
+ style.align = Some(content::parse_align(al)?);
+ }
+ if let Some(aligns) = content::opt_aligns(&a)? {
+ style.aligns = aligns;
}
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
@@ -608,6 +641,7 @@ impl Automation {
}
"doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
+ "doc_backdrops" => self.backdrops(&a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
out["security"] = security(self.doc(&a)?);
@@ -742,7 +776,7 @@ impl Automation {
let ids: Vec = pdfcraft_engine::sign::keychain::identities(None)
.map_err(failed)?
.iter()
- .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
+ .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) }))
.collect();
#[cfg(not(target_os = "macos"))]
let ids: Vec = Vec::new();
@@ -755,7 +789,7 @@ impl Automation {
let ids = listing
.ids
.iter()
- .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
+ .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) }))
.collect();
let unusable = listing
.unusable
diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs
index 3ce312f395..859c92da48 100644
--- a/crates/automation/src/documents.rs
+++ b/crates/automation/src/documents.rs
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, nest_rows, security, summary};
use super::paths::{child, read_input, write_atomic};
-use super::*;
+use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json};
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
@@ -279,7 +279,8 @@ impl Automation {
}
pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result {
- use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset};
+ use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, MonoCompression, Preset};
+ const MONO_KEYS: [&str; 4] = ["downsample", "ppi", "above_ppi", "compression"];
let id = self.doc(a)?.id;
let before = self.doc(a)?.bytes.len();
let path = self.resolve(a.str("path")?, true)?;
@@ -317,6 +318,38 @@ impl Automation {
};
settings.color = images("color", settings.color)?;
settings.gray = images("gray", settings.gray)?;
+ if let Some(mono) = a.get("mono") {
+ let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, jbig2, retain)"));
+ let mono = mono.as_object().ok_or_else(|| {
+ ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain }".into())
+ })?;
+ // A misspelled key would otherwise leave the preset's setting in place unnoticed.
+ if let Some(key) = mono.keys().find(|k| !MONO_KEYS.contains(&k.as_str())) {
+ return Err(ToolError::InvalidArgs(format!("unknown mono key {key:?} ({})", MONO_KEYS.join(", "))));
+ }
+ let m = &mut settings.mono;
+ if let Some(d) = mono.get("downsample") {
+ m.downsample = d.as_bool().ok_or_else(|| ToolError::InvalidArgs(format!("mono downsample must be true or false, not {d}")))?;
+ }
+ for (key, ppi) in [("ppi", &mut m.target_ppi), ("above_ppi", &mut m.above_ppi)] {
+ if let Some(p) = mono.get(key) {
+ *ppi = p
+ .as_f64()
+ .filter(|p| p.is_finite())
+ .ok_or_else(|| ToolError::InvalidArgs(format!("mono {key} must be a number, not {p}")))?
+ .clamp(9.0, 2400.0);
+ }
+ }
+ m.above_ppi = m.above_ppi.max(m.target_ppi);
+ if let Some(c) = mono.get("compression") {
+ m.compression = match c.as_str() {
+ Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4,
+ Some("jbig2") => MonoCompression::Jbig2,
+ Some("retain") => MonoCompression::Retain,
+ _ => return Err(unknown(c)),
+ };
+ }
+ }
for (key, flag) in [
("merge_fragments", &mut settings.merge_fragments),
("discard_thumbnails", &mut settings.discard_thumbnails),
@@ -356,7 +389,9 @@ impl Automation {
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
let root = doc.root()?;
- doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
+ // Through a reference too, as the optimizer reads it.
+ let version = doc.get(root).as_dict()?.get(b"Version")?.clone();
+ doc.resolve(&version).as_name().and_then(valid_version).map(str::to_owned)
})
.flatten();
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
@@ -416,6 +451,28 @@ impl Automation {
Ok(out)
}
+ /// Pages whose own backdrop hides a background (see `pdfcraft_edit::backdrop`).
+ pub(crate) fn backdrops(&mut self, a: &Args) -> Result {
+ let n = self.doc(a)?.info.pages.len();
+ let pages = match a.opt_ints("pages")? {
+ Some(_) => self.pages(a, "pages")?,
+ None => (0..n).collect(),
+ };
+ let doc = self.doc(a)?;
+ let found: Vec = pages
+ .iter()
+ .zip(doc.page_backdrops(&pages))
+ .filter_map(|(p, found)| {
+ let kind = match found? {
+ pdfcraft_engine::Backdrop::Fill => "fill",
+ pdfcraft_engine::Backdrop::Image => "image",
+ };
+ Some(json!({ "page": p + 1, "backdrop": kind }))
+ })
+ .collect();
+ Ok(json!({ "pages": found }))
+ }
+
pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result {
use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark};
let n = self.doc(a)?.info.pages.len();
@@ -483,6 +540,7 @@ impl Automation {
color: color("color", [1.0; 3])?,
opacity: a.opt_num("opacity")?.unwrap_or(1.0),
scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0),
+ replace_backdrop: a.opt_bool("replace_backdrop")?.unwrap_or(true),
..Background::default()
},
replace,
diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs
index 71fbc67b3b..adf0bd0884 100644
--- a/crates/automation/src/forms.rs
+++ b/crates/automation/src/forms.rs
@@ -1,7 +1,7 @@
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
//! prepare a form (add fields one at a time or as a table, change and delete them).
-use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
+use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable};
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
use serde_json::{Value, json};
@@ -94,13 +94,20 @@ fn look_arg(v: &Value) -> Result {
})
}
-/// `{"min": 0, "max": 100}` or `"none"`.
+/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it.
fn validate_arg(v: &Value) -> Result {
if v.as_str() == Some("none") {
return Ok(Validate::None);
}
let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?;
- let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64));
+ if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) {
+ return Err(bad(format!("unknown key {k:?} in validate (min, max)")));
+ }
+ let bound = |k: &str| match o.get(k) {
+ None | Some(Value::Null) => Ok(None),
+ Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))),
+ };
+ let (min, max) = (bound("min")?, bound("max")?);
if min.is_none() && max.is_none() {
return Err(bad("validate needs min, max or both"));
}
@@ -113,25 +120,67 @@ fn calculate_arg(v: &Value) -> Result {
return Ok(Calculate::None);
}
let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?;
- if let Some(n) = o.get("notation").and_then(Value::as_str) {
+ // Before anything else is read, so a script given beside a notation isn't dropped unnoticed.
+ if o.contains_key("script") {
+ return Err(bad("a custom calculate script is set with form_set_script (event: calculate)"));
+ }
+ if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) {
+ return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)")));
+ }
+ // A null is no value, as for the tool's own arguments.
+ let get = |k: &str| o.get(k).filter(|v| !v.is_null());
+ if let Some(n) = get("notation") {
+ if get("op").is_some() || get("fields").is_some() {
+ return Err(bad("calculate takes a notation, or op and fields, not both"));
+ }
+ let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?;
+ // Refused rather than stored as a different formula.
+ if !notation_storable(n) {
+ return Err(bad(NOTATION_NOT_STORABLE));
+ }
return Ok(Calculate::Notation(n.to_string()));
}
- let op = match o.get("op").and_then(Value::as_str).unwrap_or("") {
- "sum" => CalcOp::Sum,
- "product" => CalcOp::Product,
- "average" => CalcOp::Average,
- "min" | "minimum" => CalcOp::Minimum,
- "max" | "maximum" => CalcOp::Maximum,
- x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))),
+ let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?;
+ let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? {
+ "minimum" => CalcOp::Minimum,
+ "maximum" => CalcOp::Maximum,
+ x => {
+ CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))?
+ }
};
- let fields: Vec =
- o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default();
+ let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?;
+ let fields: Vec = fields
+ .iter()
+ .map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)")))
+ .collect::>()?;
if fields.is_empty() {
return Err(bad("calculate needs fields"));
}
Ok(Calculate::Simple { op, fields })
}
+/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it.
+fn op_name(op: CalcOp) -> &'static str {
+ match op {
+ CalcOp::Sum => "sum",
+ CalcOp::Product => "product",
+ CalcOp::Average => "average",
+ CalcOp::Minimum => "min",
+ CalcOp::Maximum => "max",
+ }
+}
+
+/// A field's calculation as `form_fields` and `form_calculation_order` show it, in
+/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one.
+fn calculate_json(f: &FormField) -> Option {
+ match (&f.actions.calculate, &f.actions.scripts.calculate) {
+ (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
+ (Calculate::Notation(n), _) => Some(json!({ "notation": n })),
+ (Calculate::None, Some(js)) => Some(json!({ "script": js })),
+ (Calculate::None, None) => None,
+ }
+}
+
fn format_json(f: &Format) -> Value {
match f {
Format::None => Value::Null,
@@ -220,18 +269,15 @@ impl Automation {
if let Validate::Range { min, max } = &f.actions.validate {
o.insert("validate".into(), json!({ "min": min, "max": max }));
}
- match &f.actions.calculate {
- Calculate::Simple { op, fields } => {
- o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields }));
- }
- Calculate::Notation(n) => {
- o.insert("calculate".into(), json!({ "notation": n }));
- }
- Calculate::None => {}
+ if let Some(c) = calculate_json(f) {
+ o.insert("calculate".into(), c);
}
if !f.actions.unsupported.is_empty() {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
+ if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
+ o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::>()));
+ }
match f.kind {
FormFieldKind::PushButton => {
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
@@ -418,8 +464,8 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result {
- if a.get("widget").is_some() && a.get("caption").is_none() {
- return Err(bad("widget requires caption"));
+ if a.get("widget").is_some() && a.get("caption").is_none() && a.get("export_value").is_none() {
+ return Err(bad("widget requires caption or export_value"));
}
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
@@ -438,7 +484,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
- for key in ["name", "rect", "rotation", "caption", "widget"] {
+ for key in ["name", "rect", "rotation", "caption", "export_value", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -502,6 +548,15 @@ impl Automation {
};
Ok(FieldProps {
rect,
+ export_value: a
+ .opt_str("export_value")?
+ .map(|value| -> Result<_> {
+ let index = a.opt_int("widget")?.unwrap_or(1);
+ let index =
+ usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
+ Ok((index, value.to_owned()))
+ })
+ .transpose()?,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
caption: a
@@ -600,17 +655,23 @@ impl Automation {
};
let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?;
out["tab_order"] = self.tab_sequence(a)?;
+ out["page_tabs"] = self.page_tabs(a)?;
return Ok(out);
}
- let order = match a.str("order")? {
- "row" | "rows" => pdfcraft_engine::TabOrder::Row,
- "column" | "columns" => pdfcraft_engine::TabOrder::Column,
- "structure" => pdfcraft_engine::TabOrder::Structure,
- "annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations,
- o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
+ let order = match a.opt_str("order")? {
+ None => None,
+ Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row),
+ Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column),
+ Some("structure") => Some(pdfcraft_engine::TabOrder::Structure),
+ Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations),
+ Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
+ };
+ let mut out = match order {
+ Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?,
+ None => json!({}),
};
- let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?;
out["tab_order"] = self.tab_sequence(a)?;
+ out["page_tabs"] = self.page_tabs(a)?;
Ok(out)
}
@@ -647,15 +708,28 @@ impl Automation {
};
let order = self.doc(a)?.calculation_order();
let issue = |i: &CalculationIssue| match i {
+ CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }),
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
+ CalculationIssue::BuiltNames => json!({ "issue": "built_names" }),
};
+ // By field object, not name: two calculated fields can share a name in a malformed form.
+ let doc = self.doc(a)?;
+ let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect();
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::>());
out["fields"] = order
.fields
.iter()
- .map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::>() }))
+ .map(|f| {
+ json!({
+ "name": f.name,
+ "formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)),
+ "reads": f.reads,
+ "listed": f.listed,
+ "issues": f.issues.iter().map(issue).collect::>(),
+ })
+ })
.collect();
out["extra"] = json!(order.extra);
out["fixable"] = json!(order.fixable());
@@ -671,12 +745,35 @@ impl Automation {
Ok(json!(seq.iter().map(|x| x.1).collect::>()))
}
+ /// Each page's tab order (Page properties ▸ Tab order), in page order.
+ fn page_tabs(&self, a: &Args) -> Result {
+ let names: Vec<&str> = self
+ .doc(a)?
+ .tab_orders()
+ .into_iter()
+ .map(|o| match o {
+ pdfcraft_engine::TabOrder::Row => "row",
+ pdfcraft_engine::TabOrder::Column => "column",
+ pdfcraft_engine::TabOrder::Structure => "structure",
+ pdfcraft_engine::TabOrder::Annotations => "annotations",
+ })
+ .collect();
+ Ok(json!(names))
+ }
+
pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result {
let name = a.str("field")?.to_owned();
if !self.doc(a)?.form.iter().any(|f| f.name == name) {
return Err(failed(format!("there is no field named {name:?} (see form_fields)")));
}
- self.apply(a, Edit::DeleteField { name })
+ let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) };
+ let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len());
+ let widget = usize::try_from(widget)
+ .ok()
+ .and_then(|w| w.checked_sub(1))
+ .filter(|w| *w < count)
+ .ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?;
+ self.apply(a, Edit::DeleteWidget { name, widget })
}
pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result {
diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs
index c8710aa509..9e93d4c347 100644
--- a/crates/automation/src/json.rs
+++ b/crates/automation/src/json.rs
@@ -1,5 +1,5 @@
use super::error::{Result, ToolError};
-use super::*;
+use super::{Document, Value, comments, json};
/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at
/// most 14 400 across).
@@ -7,6 +7,36 @@ const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
// ---- JSON helpers ----------------------------------------------------------------------------
+/// The most bookmark levels `bookmark_from_text_styles` takes.
+const MAX_STYLE_LEVELS: usize = 9;
+
+/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each
+/// `{ font, size, bold, italic }` as text_styles lists them.
+pub(crate) fn text_style_levels(a: &Args) -> Result> {
+ let levels =
+ a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?;
+ if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS {
+ return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles")));
+ }
+ levels
+ .iter()
+ .enumerate()
+ .map(|(i, v)| {
+ let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}"));
+ let font =
+ v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?;
+ let size =
+ v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?;
+ let flag = |key: &str| match v.get(key) {
+ None | Some(Value::Null) => Ok(false),
+ Some(Value::Bool(b)) => Ok(*b),
+ Some(_) => Err(bad(&format!("{key} must be true or false"))),
+ };
+ Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? })
+ })
+ .collect()
+}
+
pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result> {
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"))?;
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index e1ae0141c4..f1910867c9 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -62,7 +62,7 @@ use error::{Result, failed};
pub(crate) use json::destination;
pub use json::view_rect;
use json::{Args, info, summary};
-use pages::{DEFAULT_DPI, MAX_DPI, encode_png};
+use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content};
#[cfg(test)]
use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel};
#[cfg(test)]
diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs
index d9ea9e5c4f..8c793be153 100644
--- a/crates/automation/src/pages.rs
+++ b/crates/automation/src/pages.rs
@@ -1,7 +1,7 @@
use super::dispatch::Content;
use super::error::{Result, ToolError, failed};
use super::json::{Args, one_based};
-use super::*;
+use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools};
/// Default and maximum resolution for `page_render`.
pub(crate) const DEFAULT_DPI: f64 = 96.0;
@@ -65,7 +65,9 @@ impl Automation {
Ok(&mut entry.1)
}
- pub(crate) fn page_render(&mut self, a: &Args) -> Result {
+ /// The page as a PNG, followed by a JSON part listing the render warnings when there are any,
+ /// so a caller can tell that the picture is incomplete, as `pdfcraft-cli render` does (#4340).
+ pub(crate) fn page_render(&mut self, a: &Args) -> Result> {
let id = self.doc(a)?.id;
let page = self.page(a)?;
let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI);
@@ -98,8 +100,7 @@ impl Automation {
if let Some(e) = out.error {
return Err(failed(format!("page {} could not be rendered: {e}", page + 1)));
}
- let data = encode_png(out.width, out.height, &out.rgba)?;
- Ok(Content::Png { data, width: out.width, height: out.height })
+ rendered_content(&out, page)
}
/// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels
@@ -324,6 +325,29 @@ pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Resul
pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
}
+/// The output of a page-rendering tool: the PNG of the 0-based `page`, followed by a JSON part
+/// listing its render warnings when there are any. Without warnings it is the PNG alone, as it
+/// always was.
+pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) -> Result> {
+ let data = encode_png(out.width, out.height, &out.rgba)?;
+ let mut content = vec![Content::Png { data, width: out.width, height: out.height }];
+ if !out.warnings.is_empty() {
+ // Sentences, like the other tools' `warnings`, so command_run can add its own to the list.
+ let warnings: Vec<&str> = out.warnings.iter().map(|&w| render_warning_text(w)).collect();
+ content.push(Content::Json(json!({ "page": page.saturating_add(1), "warnings": warnings })));
+ }
+ Ok(content)
+}
+
+/// What a render warning means, in the words `pdfcraft-cli render` prints for it.
+fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str {
+ use pdfcraft_render::RenderWarning;
+ match warning {
+ RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
+ RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
+ }
+}
+
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
pub(crate) fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts {
let extract = |r: &mut PageRenderer, p: usize| {
diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs
index fb514df69f..e13d0a1452 100644
--- a/crates/automation/src/paths.rs
+++ b/crates/automation/src/paths.rs
@@ -1,5 +1,5 @@
use super::error::{Result, failed};
-use super::*;
+use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes};
impl Automation {
// ---- paths -------------------------------------------------------------------------------
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 4b4ac8873b..c6c200cece 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -121,7 +121,7 @@ pub fn tools() -> Vec {
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.")
+ 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; black-and-white images go to the larger of ppi and 300) 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"},
@@ -167,7 +167,7 @@ pub fn tools() -> Vec {
)),
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); 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.")
.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).")
+ t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
.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 (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
@@ -347,6 +347,39 @@ pub fn tools() -> Vec {
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
)
.with(schema(json!({ "doc": doc() }), &["doc"])),
+ t(
+ "text_styles",
+ "List text styles",
+ "The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.",
+ )
+ .ro()
+ .with(schema(json!({ "doc": doc() }), &["doc"])),
+ t(
+ "bookmark_from_text_styles",
+ "New bookmarks from text styles",
+ "Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
+ )
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "levels": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 9,
+ "items": {
+ "type": "object",
+ "properties": {
+ "font": { "type": "string", "minLength": 1 },
+ "size": { "type": "number", "exclusiveMinimum": 0 },
+ "bold": { "type": "boolean" },
+ "italic": { "type": "boolean" }
+ },
+ "required": ["font", "size"]
+ }
+ }
+ }),
+ &["doc", "levels"],
+ )),
t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema(
json!({
"doc": doc(),
@@ -358,7 +391,7 @@ pub fn tools() -> Vec {
}),
&["doc", "from", "to"],
)),
- t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
+ t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@@ -442,8 +475,9 @@ pub fn tools() -> Vec {
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
+ "export_value": { "type": "string", "description": "Checkbox/radio export value; preserves appearance states and selection. Requires a single field." },
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
- "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
+ "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption or export value changes (1-based, default 1). Requires caption or export_value. See form_fields.captions and export_values." },
"read_only": { "type": "boolean" },
"required": { "type": "boolean" },
"multiline": { "type": "boolean" },
@@ -459,17 +493,17 @@ pub fn tools() -> Vec {
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
- "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." },
+ "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." },
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
}),
&["doc"],
)),
- t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
+ t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.")
.with(schema(
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
&["doc"],
)),
- t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
+ t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with its `formula` (as form_fields shows calculate), the fields it reads (`reads`; a script's names built at run time aren't known) and its `issues`: missing_field (it reads `field`, which the form doesn't have, so it gets no value there), runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it) and built_names (its script also reads fields by names it builds at run time, which aren't checked). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
.cmd("form.calculation_order")
.with(schema(
json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }),
@@ -478,11 +512,11 @@ pub fn tools() -> Vec {
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
- t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
+ t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
- t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
+ t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.")
.destructive()
- .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])),
+ .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])),
t(
"redact_mark",
"Mark for redaction",
@@ -603,6 +637,7 @@ pub fn tools() -> Vec {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
+ "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
}),
&["doc", "page", "text"],
@@ -625,6 +660,7 @@ pub fn tools() -> Vec {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
+ "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
@@ -886,7 +922,7 @@ pub fn tools() -> Vec {
)),
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
- t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
+ t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t(
"doc_protect",
"Protect with passwords",
@@ -996,12 +1032,16 @@ pub fn tools() -> Vec {
}),
&["doc"],
)),
- t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. Undoable.")
+ t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. A page that paints its own opaque white backdrop would hide it, so that backdrop is replaced unless replace_backdrop is false (removing the background turns it back on where that is still safe, and doc_remove_marks warns about the pages where it isn't); a scanned page's picture still hides it (doc_backdrops lists both). Undoable.")
.cmd("edit.background")
.with(schema(
- json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }),
+ json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }),
&["doc"],
)),
+ t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.")
+ .ro()
+ .cmd("edit.background")
+ .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])),
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
@@ -1135,7 +1175,7 @@ pub fn tools() -> Vec {
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"])),
- t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
+ t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
.cmd("tools.js_console")
.with(schema(
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
@@ -1154,7 +1194,7 @@ pub fn tools() -> Vec {
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. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
+ t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), 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. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
.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.")
@@ -1241,7 +1281,7 @@ pub fn tools() -> Vec {
.ro()
.cmd("ocr.recognize")
.with(schema(json!({}), &[])),
- t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")
+ t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.")
.cmd("export.all_images")
.with(schema(
json!({
@@ -1258,7 +1298,7 @@ pub fn tools() -> Vec {
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`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), 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).",
)
.cmd("sign.fill.text")
.with(schema(
@@ -1269,6 +1309,7 @@ pub fn tools() -> Vec {
"at": point(),
"text": { "type": "string", "minLength": 1 },
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
+ "color": color(),
"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)." },
@@ -1299,7 +1340,7 @@ pub fn tools() -> Vec {
}),
&["from"],
)),
- t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.")
+ t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.")
.cmd("optimize.reduce")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema(
@@ -1387,6 +1428,7 @@ pub fn tools() -> Vec {
"size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#rrggbb" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
+ "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"underline": { "type": "boolean" },
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
@@ -1400,7 +1442,7 @@ pub fn tools() -> Vec {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
- t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
+ t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
@@ -1411,6 +1453,7 @@ pub fn tools() -> Vec {
"merge_fragments": { "type": "boolean" },
"color": { "type": "object" },
"gray": { "type": "object" },
+ "mono": { "type": "object" },
"discard_thumbnails": { "type": "boolean" },
"discard_alternate_images": { "type": "boolean" },
"discard_tags": { "type": "boolean" },
@@ -1445,7 +1488,7 @@ pub fn tools() -> Vec {
.destructive().with(schema(json!({"steps":{"type":"array","items":schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])},"stop_on_error":{"type":"boolean"}}), &["steps"])),
t("doc_inspect", "Inspect open documents", "With doc, return doc_info; otherwise return information for every open document (empty documents array when none are open).")
.ro().with(schema(json!({"doc":doc()}), &[])),
- t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open.")
+ t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open. Render warnings follow the image as page_render gives them.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[])),
]
}
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index 23351db9aa..6a5765b911 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -516,6 +516,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() {
assert_eq!(result["status"], "passed");
}
+/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18,
+/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693).
+fn styled_headings_pdf() -> Vec {
+ let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \
+ BT /FB 14 Tf 72 640 Td (Background) Tj ET";
+ let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET";
+ let objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(),
+ format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()),
+ format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
+ ];
+ 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
+}
+
+/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the
+/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why
+/// when it can't.
+#[test]
+fn bookmarks_from_text_styles_through_tools() {
+ let dir = workdir("text-style-bookmarks");
+ std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
+ let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone();
+ assert_eq!(
+ styles,
+ json!([
+ { "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" },
+ { "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" },
+ { "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" },
+ ])
+ );
+ // The styles as text_styles gave them, the chapters first.
+ let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]);
+ let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels }));
+ let top = &r["bookmarks"][0];
+ assert_eq!(top["title"], "Untitled");
+ let titles: Vec<(&str, u64)> =
+ top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect();
+ assert_eq!(titles, [("Introduction", 1), ("Results", 2)]);
+ assert_eq!(top["children"][0]["children"][0]["title"], "Background");
+ assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles");
+ // What can't be done says why.
+ let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels }));
+ assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_))));
+ assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size")));
+ assert!(
+ matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold"))
+ );
+ let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }]));
+ assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}");
+ let too_many: Vec = (0..10).map(|_| styles[0].clone()).collect();
+ assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_))));
+}
+
/// A one-page PDF whose form holds one text field named `name`.
fn form_pdf(name: &str) -> Vec {
let objs: Vec = vec![
@@ -2030,21 +2102,33 @@ fn comments_through_tools() {
let dir = workdir("comments");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let id = add_comment_examples(&mut a, doc);
+ let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
+ let rect = verify_comment_listing(&list, &id);
+ edit_rectangle_comment(&mut a, doc, &rect);
+ edit_text_comment(&mut a, doc, &list);
+ verify_comment_errors(&mut a, doc, &id);
+ save_and_reopen_comments(&dir, &mut a, doc);
+}
+
+fn add_comment_examples(a: &mut Automation, doc: u64) -> String {
// "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page.
- let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
+ let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
assert_eq!(hl["comment"]["lines"], 1);
let id = hl["comment"]["id"].as_str().unwrap().to_string();
- ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" }));
- ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 }));
- ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 }));
- ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] }));
- ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] }));
- ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" }));
- ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" }));
+ ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" }));
+ ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 }));
+ ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 }));
+ ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] }));
+ ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] }));
+ ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" }));
+ ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" }));
+ id
+}
- let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
+fn verify_comment_listing(list: &Value, id: &str) -> Value {
assert_eq!(list["count"], 6, "{list}");
- let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone();
+ let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check")));
assert_eq!(c["status"], "Accepted");
assert_eq!(c["replies"].as_array().unwrap().len(), 1);
@@ -2057,33 +2141,38 @@ fn comments_through_tools() {
assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0]));
let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone();
assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point");
+ rect
+}
+fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) {
// Edit by page + index; several changes are one undo step.
- let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap());
- ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" }));
- let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
+ let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap());
+ ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" }));
+ let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone();
assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved")));
assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0]));
- let undo = ok(&mut a, "edit_undo", json!({ "doc": doc }));
+ let undo = ok(a, "edit_undo", json!({ "doc": doc }));
assert_eq!(undo["undone"], "Edit comment");
- ok(&mut a, "edit_redo", json!({ "doc": doc }));
+ ok(a, "edit_redo", json!({ "doc": doc }));
// Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none.
- ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
- ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
+ ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
+ ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_))));
- let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
+ let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
assert!(matches!(
a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })),
Err(ToolError::Failed(_))
));
+}
+fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) {
// Editing a text box re-fits its rectangle to the new text: the wrap width and top edge
// stay, the height follows the wrapped lines.
- let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
+ let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
let long = "the quick brown fox jumps over the lazy dog ".repeat(3);
- ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
- let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
+ ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
+ let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
let r: Vec = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}");
@@ -2092,9 +2181,11 @@ fn comments_through_tools() {
// The rendered page shows the rectangle's border.
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
assert!(matches!(png[0], Content::Png { .. }));
+}
- ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id }));
- assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5);
+fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) {
+ ok(a, "comment_delete", json!({ "doc": doc, "id": id }));
+ assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5);
assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_))));
@@ -2103,10 +2194,11 @@ fn comments_through_tools() {
Err(ToolError::InvalidArgs(_))
));
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_))));
+}
- // Comments survive a save and reopen.
- ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" }));
- let mut b = auto(&dir);
+fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) {
+ ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" }));
+ let mut b = auto(dir);
let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5);
}
@@ -2134,6 +2226,35 @@ fn nested_replies_stay_in_the_listing() {
assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}");
}
+/// comment_delete on a reply deletes that reply and the replies to it, as the Comments panel's
+/// reply menu does: the comment and its other replies stay, and one undo brings it back.
+#[test]
+fn deleting_a_reply_keeps_its_comment_and_the_other_replies() {
+ let dir = workdir("delete-reply");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" }));
+ let root_id = root["comment"]["id"].as_str().unwrap().to_string();
+ ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "WRONG_REPLY", "author": "B" }));
+ ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "RIGHT_REPLY", "author": "C" }));
+ let replies = |a: &mut Automation| -> Vec {
+ let list = ok(a, "comment_list", json!({ "doc": doc }));
+ assert_eq!(list["count"], 1, "{list}");
+ list["comments"][0]["replies"].as_array().unwrap().clone()
+ };
+ let wrong = replies(&mut a).into_iter().find(|r| r["contents"] == "WRONG_REPLY").unwrap();
+ ok(&mut a, "comment_reply", json!({ "doc": doc, "id": wrong["id"], "text": "ANSWER_TO_WRONG", "author": "D" }));
+ let texts = |a: &mut Automation| -> Vec { replies(a).iter().filter_map(|r| r["contents"].as_str().map(str::to_owned)).collect() };
+ assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]);
+
+ ok(&mut a, "comment_delete", json!({ "doc": doc, "page": wrong["page"], "index": wrong["index"] }));
+ let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
+ assert_eq!(list["comments"][0]["contents"], "ROOT_NOTE", "{list}");
+ assert_eq!(texts(&mut a), ["RIGHT_REPLY"], "the reply took the answer to it along");
+ assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Delete comment");
+ assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]);
+}
+
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment.
#[test]
@@ -2548,6 +2669,29 @@ fn orphan_form_fields_through_tools() {
assert_ne!(v, "Filled by an agent");
}
+/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat
+/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text.
+#[test]
+fn auto_sized_text_follows_a_tall_field() {
+ let objs: Vec<&str> = vec![
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2
+ "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3
+ "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6
+ ];
+ let dir = workdir("auto-size-tall");
+ std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } }));
+ let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" }));
+ let r = &found["matches"][0]["rects"][0];
+ let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap();
+ assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})");
+}
+
#[test]
fn reversing_pages_through_tools() {
let dir = workdir("reverse");
@@ -2809,6 +2953,12 @@ fn accessibility_check_report_and_fixes_through_tools() {
let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap();
assert!(manual_html.contains("Needs manual check: 1
"));
assert!(!manual_html.contains("no problems"));
+}
+
+#[test]
+fn accessibility_figure_tags_and_alternative_text_through_tools() {
+ let dir = workdir("a11y-figures");
+ let mut a = auto(&dir);
// Figures: list, describe, mark decorative.
std::fs::write(
dir.join("figures.pdf"),
@@ -3546,6 +3696,38 @@ fn fill_sign_text_font_size_is_saved_and_validated() {
));
}
+#[test]
+fn fill_sign_text_and_date_colours_are_saved_and_validated() {
+ let dir = workdir("fill-sign-text-colour");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (kind, y) in [("text", 40), ("date", 80)] {
+ ok(
+ &mut a,
+ "fill_sign_add",
+ json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "text": "Blue", "color": "#3366CC", "font_size": 6.5 }),
+ );
+ }
+ for (kind, color) in [("text", "not-a-colour"), ("signature", "#3366CC"), ("check", "#3366CC")] {
+ assert!(matches!(
+ a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 100], "text": "No", "color": color })),
+ Err(ToolError::InvalidArgs(_))
+ ));
+ }
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "coloured.pdf" }));
+ let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("coloured.pdf")).unwrap())).unwrap();
+ let page = pdfcraft_model::pages(&pdf).remove(0);
+ let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap());
+ let annotations = annotations.as_array().unwrap();
+ assert_eq!(annotations.len(), 2, "invalid requests add nothing");
+ for reference in annotations {
+ let annotation = pdf.resolve(reference);
+ // FreeText stores its text colour in DA; C is the annotation background.
+ let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap();
+ assert_eq!(String::from_utf8(da.bytes.to_vec()).unwrap(), "0.2 0.4 0.8 rg /Helv 6.5 Tf");
+ }
+}
+
#[test]
fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
let dir = workdir("image-signatures");
@@ -4004,6 +4186,29 @@ fn preparing_a_form_through_tools() {
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
}
+#[test]
+fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() {
+ // #4949: two buttons placed separately are two groups; naming one after the other makes
+ // them one group, and a clashing export value is renamed instead of shared.
+ let dir = workdir("radio-join");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" }));
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" }));
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } }));
+ let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" }));
+ assert_eq!(r["field"], "size");
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" }));
+ let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap();
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let f = fields["fields"].as_array().unwrap();
+ assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["size"], "one group");
+ assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value");
+ assert_eq!(f[0]["value"], "S", "the group keeps its selection");
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } }));
+ assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1");
+}
+
#[test]
fn rotating_a_field_through_tools() {
let dir = workdir("rotate-field");
@@ -4914,6 +5119,168 @@ fn the_calculation_order_is_read_fixed_and_set() {
assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated");
}
+/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`.
+fn two_calculations_with_one_name() -> Vec {
+ let widget = |name: &str, y: u32, reads: &str| {
+ format!(
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>",
+ y + 20
+ )
+ };
+ pdf_of(&[
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
+ "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
+ "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>",
+ &widget("dup", 700, "x"),
+ &widget("dup", 660, "src"),
+ &widget("src", 620, "x"),
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>",
+ ])
+}
+
+/// Each row of the calculation order gets its own field's formula, even where two fields share a
+/// name (a malformed form): the first dup sums src, the second x.
+#[test]
+fn the_calculation_order_gives_each_field_its_own_formula() {
+ let dir = workdir("calcdup");
+ std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
+ assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"]));
+ let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
+ assert_eq!(
+ formulas,
+ [json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })]
+ );
+}
+
+/// The calculation order through the tool shows each formula, in form_set_props's shape or as a
+/// script, and flags a read of a field the form doesn't have and a script that builds the names
+/// it reads; form_fields shows a custom script too.
+#[test]
+fn the_calculation_order_shows_formulas_and_what_they_cannot_read() {
+ let dir = workdir("calcformulas");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (i, name) in ["qty", "price", "sub", "tax", "total"].into_iter().enumerate() {
+ let y = 20 + 30 * i;
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ let script = "event.value = getField(\"sub\").value / 10 + getField(\"row\" + 1).value;";
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * pric" } }));
+ ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "tax", "event": "calculate", "script": script }));
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } }));
+ let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
+ assert_eq!(r["calculation_order"], json!(["sub", "tax", "total"]));
+ let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
+ assert_eq!(formulas, [json!({ "notation": "qty * pric" }), json!({ "script": script }), json!({ "op": "sum", "fields": ["sub", "tax"] })]);
+ assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "missing_field", "field": "pric" }]));
+ assert_eq!(r["fields"][1]["reads"], json!(["sub"]));
+ assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "built_names" }]));
+ assert_eq!(r["fields"][2]["issues"], json!([]));
+ assert_eq!(r["fixable"], false, "neither is an order to fix");
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap();
+ assert_eq!(tax["calculate"], json!({ "script": script }));
+ // A script is set with form_set_script, and form_set_props says so, also when a notation
+ // comes with it.
+ for calculate in [json!({ "script": script }), json!({ "notation": "qty * 2", "script": script })] {
+ let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": calculate }));
+ assert!(e.to_string().contains("form_set_script"), "{calculate}: {e}");
+ }
+}
+
+/// Every operation comes back from form_fields and form_calculation_order as form_set_props takes
+/// it, so a formula can be read and given back: product and average came back as prd and avg,
+/// which it refused.
+#[test]
+fn every_calculation_op_can_be_read_and_given_back() {
+ let dir = workdir("calcops");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ for op in ["sum", "product", "average", "min", "max"] {
+ let calculate = json!({ "op": op, "fields": ["a", "b"] });
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let shown = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap()["calculate"].clone();
+ let order = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
+ assert_eq!((&shown, &order["fields"][0]["formula"]), (&calculate, &calculate), "{op}");
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": shown }));
+ }
+}
+
+/// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't
+/// know, a value of the wrong type, a notation beside op and fields) as a bad argument, before
+/// the edit runs, and changes nothing. A validate read back from form_fields, with null for the
+/// bound it doesn't have, is taken back.
+#[test]
+fn calculate_and_validate_refuse_what_they_would_drop() {
+ let dir = workdir("calcstrict");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ let set = json!({ "op": "sum", "fields": ["a", "b"] });
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": set, "validate": { "min": 0, "max": 10 } }));
+ let total = |a: &mut Automation| {
+ let fields = ok(a, "form_fields", json!({ "doc": doc }));
+ fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap().clone()
+ };
+ for (calculate, says) in [
+ (json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"),
+ (json!({ "notation": "a", "op": "sum" }), "not both"),
+ (json!({ "notation": "a", "fields": ["b"] }), "not both"),
+ // A notation that wouldn't read back as itself from the script's comment.
+ (json!({ "notation": "a EVCALC b" }), "can't be stored"),
+ (json!({ "notation": "a */ x /*" }), "can't be stored"),
+ (json!({ "notation": " " }), "can't be stored"),
+ (json!({ "op": "sum", "fields": ["a", 5] }), "field names"),
+ (json!({ "op": "sum", "fields": "a" }), "list of field names"),
+ (json!({ "notation": 5 }), "notation must be a string"),
+ (json!({ "op": 5, "fields": ["a"] }), "op must be"),
+ (json!({ "fields": ["a"] }), "needs op"),
+ (json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""),
+ ] {
+ // InvalidArgs, not Failed: an unstorable notation is refused by the tool itself, not
+ // only by the edit underneath it.
+ let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
+ assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{calculate}: {e:?}");
+ }
+ for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] {
+ let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate }));
+ assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{validate}: {e:?}");
+ }
+ let t = total(&mut a);
+ assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed");
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": { "min": null, "max": 5 } }));
+ let shown = total(&mut a)["validate"].clone();
+ assert_eq!(shown, json!({ "min": null, "max": 5.0 }));
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": shown }));
+}
+
+#[test]
+fn page_tab_orders_are_read_and_set_per_page_through_tools() {
+ let dir = workdir("pagetabs");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc }));
+ assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"]));
+ assert!(r["undo"].is_null(), "reading changes nothing: {r}");
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] }));
+ assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"]));
+ assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true)));
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] }));
+ assert_eq!(r["page_tabs"], json!(["column", "structure", "column"]));
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] }));
+ assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"]));
+}
+
#[test]
fn tab_order_and_field_appearance_through_tools() {
let dir = workdir("taborder");
@@ -5260,6 +5627,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
#[test]
fn local_gotor_link_open_applies_destinations_and_honors_root() {
+ let root = local_gotor_fixtures();
+ let mut a = auto(&root);
+ assert_relative_gotor_destination(&mut a);
+ assert_absolute_gotor_policy(&mut a);
+ assert_unapplied_gotor_destinations(&mut a);
+ assert_encrypted_gotor_passwords(&mut a);
+ assert_unsupported_gotor_targets(&mut a);
+ assert_gotor_root_boundaries(&mut a);
+ assert_url_links_cannot_open_as_gotor(&mut a);
+}
+
+fn local_gotor_fixtures() -> PathBuf {
let (base, root) = sandbox("gotor-open");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("target.pdf"), fixture(3)).unwrap();
@@ -5277,12 +5656,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap();
link_dir(&root, "outside-link", &base.join("outside"));
std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap();
+ root
+}
- let mut a = auto(&root);
- let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
- let listed = ok(&mut a, "link_list", json!({ "doc": source }));
+fn assert_relative_gotor_destination(a: &mut Automation) {
+ let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
+ let listed = ok(a, "link_list", json!({ "doc": source }));
assert_eq!(listed["links"][0]["other"], "GoToR");
- let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
+ let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
assert_eq!(opened["path"], "target.pdf");
assert_eq!(opened["destination_applied"], true);
assert_eq!(opened["destination"]["page"], 3);
@@ -5290,51 +5671,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
assert_eq!(opened["document"]["pages"], 3);
let target_id = opened["document"]["doc"].as_u64().unwrap();
assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3);
+}
- let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
+fn assert_absolute_gotor_policy(a: &mut Automation) {
+ let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
let before = a.session().docs().len();
for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] {
- assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
+ assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
assert_eq!(a.session().docs().len(), before);
}
- let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
+ let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
assert_eq!(opened["path"], "target.pdf");
assert_eq!(opened["destination"]["page"], 2);
+}
+fn assert_unapplied_gotor_destinations(a: &mut Automation) {
for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] {
- let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
- let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
+ let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
+ let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
assert_eq!(opened["destination_applied"], false);
assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}");
}
+}
- let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
+fn assert_encrypted_gotor_passwords(a: &mut Automation) {
+ let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
let documents_before = a.session().docs().len();
- assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
+ assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
assert_eq!(a.session().docs().len(), documents_before);
- assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
+ assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
assert_eq!(a.session().docs().len(), documents_before);
- let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
+ let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
assert_eq!(opened["document"]["encrypted"], true);
+}
+fn assert_unsupported_gotor_targets(a: &mut Automation) {
for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] {
- let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
+ let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let documents_before = a.session().docs().len();
- assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
+ assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}");
}
+}
+fn assert_gotor_root_boundaries(a: &mut Automation) {
for source_path in ["sub/escape.pdf", "symlink.pdf"] {
- let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
+ let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let documents_before = a.session().docs().len();
- assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
+ assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened");
}
+}
- let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
- ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
+fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) {
+ let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
assert!(
- matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
+ matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
);
}
@@ -5887,8 +6280,14 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
fn digital_ids_signing_and_validation_through_tools() {
let dir = workdir("signing");
let mut a = auto(&dir);
+ create_signing_identity(&mut a);
+ let doc = sign_document_and_verify_trust(&mut a, &dir);
+ verify_signature_revisions_and_bundle(&mut a, doc, &dir);
+}
+
+fn create_signing_identity(a: &mut Automation) {
let id = ok(
- &mut a,
+ a,
"sign_id_create",
json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }),
);
@@ -5897,13 +6296,15 @@ fn digital_ids_signing_and_validation_through_tools() {
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
// Eight characters at least (#1847): seven is refused.
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
+}
- let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
- assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0);
+fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 {
+ let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0);
// A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere).
assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_))));
- let store = ok(&mut a, "sign_windows_ids", json!({}));
+ let store = ok(a, "sign_windows_ids", json!({}));
let ids = store["ids"].as_array().unwrap();
assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64);
assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:")));
@@ -5917,7 +6318,7 @@ fn digital_ids_signing_and_validation_through_tools() {
Err(ToolError::InvalidArgs(_))
));
let r = ok(
- &mut a,
+ a,
"sign_document",
json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }),
);
@@ -5929,33 +6330,37 @@ fn digital_ids_signing_and_validation_through_tools() {
assert!(dir.join("signed.pdf").exists());
// Trust the ID: valid; comment afterwards: still valid, change allowed.
- let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
+ let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
assert_eq!(t["trusted"].as_array().unwrap().len(), 1);
- let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
+ let list = ok(a, "sign_list", json!({ "doc": doc }));
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")));
- ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
- let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0];
+ ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
+ let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0];
assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed")));
+ doc
+}
+
+fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) {
// A full rewrite is refused for a signed document.
assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_))));
- ok(&mut a, "doc_save", json!({ "doc": doc }));
+ ok(a, "doc_save", json!({ "doc": doc }));
// Three revisions: the original, the signature, the comment; the middle one is signed.
- let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap();
+ let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap();
assert_eq!(revs.len(), 3, "{revs:?}");
assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}");
- let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap();
- assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment");
- assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1);
+ let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment");
+ assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1);
assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_))));
- assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
- assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
+ assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
+ assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
// The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one.
use pdfcraft_engine::sign::{cms, der, pkcs12};
let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw;
let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]);
std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap();
- assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1);
- assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid");
+ assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1);
+ assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid");
}
/// The decoded text of every visible signature appearance (Helvetica forms) in a signed file.
@@ -6094,6 +6499,15 @@ fn optimizing_through_tools() {
let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap();
let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false }));
assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}");
+ // The same with the catalog's /Version given by reference: read through it.
+ let v16 = cos14.add(pdfcraft_cos::Object::name("1.6"));
+ cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::Ref(v16))).unwrap();
+ let ref16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
+ std::fs::write(dir.join("ref16.pdf"), &ref16).unwrap();
+ let ref_doc = ok(&mut a, "doc_open", json!({ "path": "ref16.pdf" }))["doc"].as_u64().unwrap();
+ let by_ref =
+ ok(&mut a, "doc_optimize", json!({ "doc": ref_doc, "path": "retained-ref.pdf", "compatibility": "retain", "object_streams": false }));
+ assert_eq!(by_ref["pdf_version"].as_str(), Some("1.6"), "{by_ref}");
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(
@@ -6173,6 +6587,240 @@ fn lzw_streams_are_stored_with_flate_through_tools() {
assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops));
}
+/// A one-page PDF drawing a 1-bit scan of text (glyph strokes with ragged edges, as a scanner
+/// leaves them) of 1000 × 400 samples, stored with Flate.
+fn scanned_page() -> Vec {
+ scan_drawn(b"q 500 0 0 200 50 500 cm /Scan Do Q")
+}
+
+/// [`scanned_page`]'s scan, drawn by `content` (which names it `/Scan`).
+fn scan_drawn(content: &[u8]) -> Vec {
+ use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
+ let (w, h) = (1000usize, 400usize);
+ let stride = w.div_ceil(8);
+ let mut data = vec![0xFFu8; stride * h];
+ let mut seed = 0x2545_F491_4F6C_DD1Du64;
+ let mut next = move || {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed
+ };
+ for line in (4..h - 20).step_by(24) {
+ let mut x = 8;
+ while x + 12 < w {
+ let wide = 3 + (next() % 5) as usize;
+ let (top, bottom) = (line + (next() % 4) as usize, line + 14);
+ for y in top..bottom {
+ let left = x + usize::from(next() % 8 == 0);
+ let right = x + wide - usize::from(next() % 8 == 0);
+ for xx in left..right {
+ data[y * stride + xx / 8] &= !(0x80 >> (xx % 8));
+ }
+ }
+ x += wide + 2 + (next() % 4) as usize;
+ }
+ }
+ let mut doc = Document::new_empty();
+ 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(1));
+ d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
+ let image = doc.add(Object::Stream(Stream::flate(d, &data)));
+ 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(b"Scan".to_vec(), Object::Ref(image));
+ let mut res = Dict::new();
+ res.set(b"XObject".to_vec(), Object::Dict(x));
+ let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.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(content));
+ let page = doc.add(Object::Dict(p));
+ doc.update_dict(pages, |d| {
+ d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
+ d.set(b"Count".to_vec(), Object::Int(1));
+ })
+ .unwrap();
+ write_full(&doc, &SaveOptions::default()).unwrap()
+}
+
+/// A scan saved as a 1-bit PNG becomes a page whose picture stays one bit per pixel (not the eight
+/// bits the PNG decodes to), and Export all images gives the picture back.
+#[test]
+fn a_one_bit_scan_stays_one_bit_and_exports() {
+ let dir = workdir("bilevel");
+ let (w, h) = (800u32, 200u32);
+ let stride = (w as usize).div_ceil(8);
+ let mut bits = vec![0xFFu8; stride * h as usize];
+ for y in 0..h as usize {
+ for x in 0..w as usize {
+ if y % 25 >= 6 && y % 25 < 18 && (x * 7 + y / 25 * 13) % 11 < 4 {
+ bits[y * stride + x / 8] &= !(0x80 >> (x % 8));
+ }
+ }
+ }
+ let mut png_bytes = Vec::new();
+ {
+ let mut enc = png::Encoder::new(&mut png_bytes, w, h);
+ enc.set_color(png::ColorType::Grayscale);
+ enc.set_depth(png::BitDepth::One);
+ enc.write_header().unwrap().write_image_data(&bits).unwrap();
+ }
+ std::fs::write(dir.join("scan.png"), &png_bytes).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.png"] }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "scan.pdf" }));
+ let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("scan.pdf")).unwrap())).unwrap();
+ let images: Vec> = saved
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*saved.get(pdfcraft_cos::ObjRef { num, generation: saved.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"BitsPerComponent")),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(images, [Some(1)]);
+ let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": "out" }));
+ let files = r["files"].as_array().unwrap();
+ assert_eq!(files.len(), 1, "{r}");
+ let exported = std::fs::read(files[0]["path"].as_str().unwrap()).unwrap();
+ let mut reader = png::Decoder::new(std::io::Cursor::new(exported)).read_info().unwrap();
+ let mut buf = vec![0; reader.output_buffer_size().unwrap()];
+ let frame = reader.next_frame(&mut buf).unwrap();
+ let white: Vec = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect();
+ let expected: Vec =
+ (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect();
+ assert_eq!(white, expected);
+}
+
+/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2,
+/// smaller than as Group 4, and exports as the same picture.
+#[test]
+fn doc_optimize_stores_black_and_white_images_as_jbig2() {
+ let dir = workdir("optimize-jbig2");
+ std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
+ let g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" }));
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } }));
+ assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}");
+ assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}");
+ let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap();
+ let filters: Vec> = written
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(filters, [b"JBIG2Decode".to_vec()]);
+ // Export ▸ Images gives the copy's picture back as it gives the scan's.
+ let copy = ok(&mut a, "doc_open", json!({ "path": "jbig2.pdf" }))["doc"].as_u64().unwrap();
+ let mut exported = |doc: u64, folder: &str| {
+ let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": folder }));
+ let files = r["files"].as_array().unwrap();
+ assert_eq!(files.len(), 1, "{r}");
+ std::fs::read(files[0]["path"].as_str().unwrap()).unwrap()
+ };
+ assert_eq!(exported(copy, "from-jbig2"), exported(doc, "from-scan"));
+}
+
+/// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with
+/// `mono: { compression: retain }`.
+#[test]
+fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() {
+ let dir = workdir("optimize-mono");
+ std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
+ let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } }));
+ assert_eq!((kept["images"].as_u64(), kept["images_recompressed"].as_u64()), (Some(1), Some(0)), "{kept}");
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" }));
+ assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}");
+ assert!(r["image_bytes_after"].as_u64() < r["image_bytes_before"].as_u64(), "{r}");
+ // The copy written stores the image as Group 4.
+ let small = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap();
+ let filters: Vec> = small
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*small.get(pdfcraft_cos::ObjRef { num, generation: small.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]);
+ // An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an
+ // object is refused, not read as the default.
+ for mono in [json!({ "compression": "jpeg" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] {
+ assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}");
+ }
+}
+
+/// A scan drawn at 600 ppi is averaged down to 300 ppi by default. `mono: { downsample: false }`
+/// keeps its size, `ppi` and `above_ppi` choose others, and values of the wrong type are refused.
+#[test]
+fn doc_optimize_averages_black_and_white_scans_down() {
+ let dir = workdir("optimize-mono-downsample");
+ // 1000 × 400 samples on 120 × 48 pt: 600 ppi.
+ std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
+ let sizes = |name: &str| image_sizes(&dir.join(name));
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" }));
+ assert_eq!(r["images_resampled"].as_u64(), Some(1), "{r}");
+ assert_eq!(sizes("small.pdf"), [(Some(500), Some(200))]);
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "downsample": false } }));
+ assert_eq!(r["images_resampled"].as_u64(), Some(0), "{r}");
+ assert_eq!(sizes("kept.pdf"), [(Some(1000), Some(400))]);
+ ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "200.pdf", "mono": { "ppi": 200, "above_ppi": 250 } }));
+ assert_eq!(sizes("200.pdf"), [(Some(333), Some(133))]);
+ for mono in [json!({ "downsample": "yes" }), json!({ "ppi": "300" }), json!({ "above": 450 })] {
+ assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}");
+ }
+}
+
+/// The width and height of every image in the PDF at `path`.
+fn image_sizes(path: &std::path::Path) -> Vec<(Option, Option)> {
+ let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
+ written
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))),
+ _ => None,
+ })
+ .collect()
+}
+
+/// The batch optimize step's `ppi` reaches black-and-white images too: they go to the larger of
+/// it and Reduce File Size's 300 ppi. Before, a batch run took every scan to 300 ppi whatever
+/// `ppi` said, so `ppi: 600` halved 600 ppi scans.
+#[test]
+fn batch_ppi_bounds_how_far_black_and_white_scans_are_resampled() {
+ let dir = workdir("batch-mono-ppi");
+ // 1000 × 400 samples on 120 × 48 pt: 600 ppi.
+ std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap();
+ let mut a = auto(&dir);
+ for (ppi, size) in [(None, (500, 200)), (Some(600), (1000, 400)), (Some(96), (500, 200))] {
+ let out = format!("out-{ppi:?}");
+ let mut args = json!({ "paths": ["scan.pdf"], "steps": ["optimize"], "out_dir": out });
+ if let Some(ppi) = ppi {
+ args["ppi"] = json!(ppi);
+ }
+ let report = ok(&mut a, "doc_batch", args);
+ assert_eq!(report["completed"], 1, "{report}");
+ assert_eq!(image_sizes(&dir.join(&out).join("scan.pdf")), [(Some(size.0), Some(size.1))], "ppi {ppi:?}");
+ }
+}
+
/// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px
/// wide on a 72 pt square (600 ppi), as some PDF printers write it.
fn printed_in_strips(rows: usize) -> Vec {
@@ -6449,6 +7097,8 @@ fn ocr_recognize_files_with_distinct_names_runs_as_before() {
}
}
+/// Field scripts set through the tools validate, calculate and format, and `js_run` reports console
+/// lines, every message with its details, and requests.
#[test]
fn javascript_through_tools() {
let dir = workdir("js");
@@ -6482,10 +7132,17 @@ fn javascript_through_tools() {
let r = ok(
&mut a,
"js_run",
- json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }),
+ json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok'); app.alert({ cMsg: 'Clear?', cTitle: 'Order', nIcon: 2, nType: 3 });" }),
);
assert_eq!(r["console"][0], "2500");
- assert_eq!(r["alerts"][0], "ok");
+ // Every message, in order, with its title, icon and buttons.
+ assert_eq!(
+ r["alerts"],
+ json!([
+ { "message": "ok", "title": null, "icon": "error", "buttons": "ok" },
+ { "message": "Clear?", "title": "Order", "icon": "question", "buttons": "yes_no_cancel" },
+ ])
+ );
assert_eq!(r["requests"][0]["page"], 2);
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
@@ -6766,7 +7423,7 @@ fn exporting_to_word_html_and_rtf() {
let dir = workdir("office");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
- for ext in ["docx", "pptx", "html", "rtf"] {
+ for ext in ["docx", "pptx", "html", "rtf", "md"] {
let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") }));
assert_eq!(r["format"], ext);
}
@@ -6775,6 +7432,10 @@ fn exporting_to_word_html_and_rtf() {
assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK"));
assert!(std::fs::read(dir.join("a.pptx")).unwrap().starts_with(b"PK"));
assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2"));
+ // #1595: Markdown, with a rule between pages.
+ let md = std::fs::read_to_string(dir.join("a.md")).unwrap();
+ assert!(md.contains("Page 1") && md.contains("Page 3") && md.matches("\n---\n").count() == 2, "{md}");
+ assert_eq!(ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "b.markdown" }))["format"], "md");
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
}
@@ -7406,6 +8067,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
check(&mut a, doc);
}
+/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo,
+/// and `js_run` reports their messages.
#[test]
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
let dir = workdir("xfa-scripts");
@@ -7434,7 +8097,7 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert!(field(&mut a, "amount_2").is_none());
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
- assert_eq!(r["alerts"], json!(["Hello 4"]));
+ assert_eq!(r["alerts"], json!([{ "message": "Hello 4", "title": null, "icon": "error", "buttons": "ok" }]));
}
#[test]
@@ -7447,7 +8110,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback}));
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"}));
assert!(out["error"].is_null(), "{out}");
- assert_eq!(out["alerts"], json!(["Console message"]));
+ assert_eq!(out["alerts"], json!([{ "message": "Console message", "title": null, "icon": "error", "buttons": "ok" }]));
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
ok(&mut a, "doc_close", json!({"doc":doc}));
let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
@@ -7457,7 +8120,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
assert_eq!(out["alerts"], json!([]));
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"}));
assert!(out["error"].is_null(), "{out}");
- assert_eq!(out["alerts"], json!(["Hello 2"]));
+ assert_eq!(out["alerts"], json!([{ "message": "Hello 2", "title": null, "icon": "error", "buttons": "ok" }]));
ok(&mut a, "xfa_to_static", json!({"doc":doc}));
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""}));
assert_eq!(out["alerts"].as_array().unwrap().len(), 1);
@@ -7881,7 +8544,7 @@ fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)";
- let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"});
+ let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 190, 40], "size": 7, "align": "right"});
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9"));
assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0);
@@ -8752,8 +9415,8 @@ fn text_box_and_callout_fill_and_border_through_tools() {
}
/// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading
-/// of #2516. A third Alpha row brings its last baseline within 2.5 × size of Bravo's, so the next
-/// read groups the two as one paragraph.
+/// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves
+/// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph.
fn three_paragraph_pdf() -> Vec {
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
use std::sync::Arc;
@@ -8802,6 +9465,10 @@ fn an_emptied_paragraph_does_not_pass_its_number_to_an_overlapping_neighbour() {
/// #2516: a paragraph number names the paragraph `text_paragraphs` listed. When an edit regroups
/// the page so that paragraph no longer exists on its own, the next edit by that number is
/// refused. It used to rewrite whichever paragraph had taken the number, and that text was gone.
+///
+/// Moving Alpha down (no text change) is the regrouping edit: #2517 wraps a rewrite that adds or
+/// keeps rows in a marked region precisely so it can't be mistaken for a neighbour by geometry
+/// alone, so a text rewrite can no longer manufacture this scenario; a pure move still can.
#[test]
fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
let dir = workdir("text-edit-stale-number");
@@ -8813,12 +9480,9 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
assert!(listed["paragraphs"][1]["text"].as_str().unwrap().starts_with("Bravo"));
// Listing again here would renumber the page, so the first edit's result says what moved.
- let first = ok(
- &mut a,
- "text_edit",
- json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
- );
- assert_eq!(first["stale_paragraphs"], json!([1, 2]), "the third row regroups Alpha with Bravo: {first}");
+ // Moving Alpha down by 21.2 pt brings its last row within 14.4 pt of Bravo, its own leading.
+ let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
+ assert_eq!(first["stale_paragraphs"], json!([1, 2]), "moving Alpha down regroups it with Bravo: {first}");
// Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph.
let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."}));
@@ -8833,13 +9497,14 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
assert!(relisted["paragraphs"][1]["text"].as_str().unwrap().starts_with("Charlie"));
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Charlie paragraph, edited."}));
let text = page_text(&mut a, doc).join(" ");
- assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, a third row."), "{text}");
+ assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, second row."), "{text}");
std::fs::remove_dir_all(dir).unwrap();
}
/// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow:
/// the merged one holds the edited text too, but rewriting it by the edited paragraph's number
-/// would drop the neighbour's text (#2516).
+/// would drop the neighbour's text (#2516). Moving Alpha down (see the previous test) is the
+/// regrouping edit, since a text rewrite is marked precisely so it cannot merge this way (#2517).
#[test]
fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
let dir = workdir("text-edit-merged-number");
@@ -8847,11 +9512,7 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
- let first = ok(
- &mut a,
- "text_edit",
- json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
- );
+ let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}");
// Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused.
@@ -8865,6 +9526,8 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
/// The editor background preview takes the same paragraph number as `text_edit`, so it hides the
/// paragraph the number named in the listing and refuses a number an edit made stale (#2516).
+/// Moving Alpha down (see the two tests above) is the regrouping edit; a text rewrite is marked
+/// precisely so it cannot merge this way (#2517).
#[test]
fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
let dir = workdir("hide-text-block-stale-number");
@@ -8872,11 +9535,7 @@ fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrou
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
- ok(
- &mut a,
- "text_edit",
- json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
- );
+ ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
// Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie.
let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2}));
@@ -9098,6 +9757,46 @@ fn bookmark_list_reports_colour_and_style() {
assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved");
}
+/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box
+/// again. Text edits take `aligns` for a paragraph's rows the same way.
+#[test]
+fn added_text_lines_take_their_own_alignments() {
+ let dir = workdir("paragraph-aligns");
+ let mut a = auto(&dir);
+ let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
+ ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
+ let rect = |a: &mut Automation, text: &str| -> Vec {
+ let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
+ let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
+ line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
+ };
+ assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
+ assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
+ // The whole box centred: the right-aligned line goes with it.
+ ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
+ let right = rect(&mut a, "Right line");
+ assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
+ assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
+ assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
+}
+
+/// Existing-row alignment is withheld until its intent survives later editing sessions.
+#[test]
+fn text_edit_row_alignment_is_refused_without_changing_existing_text() {
+ let dir = workdir("paragraph-row-aligns");
+ let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET";
+ std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap();
+ let before = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
+ let refused = err(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] }));
+ assert!(matches!(&refused, ToolError::Failed(m) | ToolError::InvalidArgs(m) if m.contains("selected-row alignment")), "{refused:?}");
+ assert_eq!(ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })), before);
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "unchanged.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "unchanged.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "text_lines", json!({ "doc": reopened, "page": 1 })), before);
+}
+
/// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its
/// options, and its page appearance shows it, before and after a save; one without Edit takes
/// only its options.
@@ -9175,3 +9874,319 @@ fn page_render_draws_stamps_without_an_appearance() {
let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count();
assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len());
}
+
+#[test]
+fn deleting_one_radio_button_through_tools_keeps_the_group() {
+ let dir = workdir("delete-radio");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (x, export) in [(20, "S"), (40, "M"), (60, "L")] {
+ let args = json!({ "doc": doc, "page": 1, "type": "radio", "rect": [x, 100, x + 14, 114], "group": "size", "export": export });
+ ok(&mut a, "form_add_field", args);
+ }
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "L" } }));
+ ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "size", "widget": 2 }));
+ let size = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"][0].clone();
+ assert_eq!((size(&mut a)["options"].clone(), size(&mut a)["value"].clone()), (json!(["S", "L"]), json!("L")), "only M is gone");
+ for widget in [0, 3, -1] {
+ let r = a.call("form_delete_field", &json!({ "doc": doc, "field": "size", "widget": widget }));
+ assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "widget {widget}: {r:?}");
+ }
+ ok(&mut a, "edit_undo", json!({ "doc": doc }));
+ assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back");
+}
+/// `/I` of the top-level field `name` in the saved file `path`.
+fn saved_selection_indices(path: &Path, name: &str) -> Option> {
+ let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
+ let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap();
+ let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
+ let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
+ let f = all
+ .iter()
+ .filter_map(|o| d.resolve(o).as_dict().cloned())
+ .find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name))
+ .unwrap();
+ f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
+}
+
+#[test]
+fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() {
+ let dir = workdir("choice-index");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" });
+ ok(&mut a, "form_add_field", args);
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } }));
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] }));
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" }));
+ assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1");
+}
+
+/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none,
+/// even when its parent field has one it would otherwise inherit.
+#[test]
+fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
+ "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>",
+ "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
+ "<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>",
+ "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>",
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
+ ];
+ let dir = workdir("kid-default");
+ std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "default": "" }));
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } }));
+ ok(&mut a, "form_reset", json!({ "doc": doc }));
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
+ assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
+}
+
+/// #2517, through the same tools as the report. One blank line is two paragraphs, and editing
+/// the first leaves the second. The neighbour at y=656.8 is one blank line below (two 14.4 pt
+/// leadings under the second row). Growing the first paragraph, saving, reopening and editing
+/// again leaves that neighbour in place. An explicit TL of 27.6 pt stays one paragraph.
+#[test]
+fn a_blank_line_stays_a_separate_paragraph_through_tools() {
+ let dir = workdir("blank-line");
+ let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \
+ BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET";
+ std::fs::write(dir.join("a.pdf"), helvetica_page(body)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let before = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
+ assert_eq!(before["count"], 2, "{before}");
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "First paragraph, edited row.\nFirst paragraph, second row." }));
+ let after = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
+ assert_eq!(after["count"], 2, "{after}");
+ let second = after["paragraphs"][1]["text"].as_str().unwrap();
+ assert!(second.contains("Second paragraph"), "{second}");
+
+ let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \
+ BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbour) Tj ET";
+ std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap();
+ let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2);
+ ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" }));
+ ok(&mut a, "doc_save", json!({ "doc": grown_doc, "path": "grown-out.pdf" }));
+ let saved = ok(&mut a, "doc_open", json!({ "path": "grown-out.pdf" }))["doc"].as_u64().unwrap();
+ let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
+ assert_eq!(after["count"], 2, "{after}");
+ assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
+ ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "text": "One\nTwo\nFour" }));
+ let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
+ assert_eq!(after["count"], 2, "{after}");
+ assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
+
+ let spaced = "BT /F1 12 Tf 27.6 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET";
+ std::fs::write(dir.join("spaced.pdf"), helvetica_page(spaced)).unwrap();
+ let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1);
+}
+
+/// #2529, through the same tools as a width drag. The typed double space falls on the wrap.
+/// Omitting `text` reflows with the saved gap count. An incremental save and a full rewrite
+/// both still have both spaces after the box is widened again.
+#[test]
+fn narrowing_then_widening_keeps_a_typed_double_space_through_tools() {
+ let dir = workdir("double-space-width");
+ std::fs::write(dir.join("a.pdf"), helvetica_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET")).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]["editor_text"], "One Two");
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "width": 45.0 }));
+ let narrow = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0];
+ assert_eq!(narrow["editor_text"], "One\nTwo", "{narrow}");
+ for (name, full) in [("narrow-inc.pdf", false), ("narrow-full.pdf", true)] {
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": name, "full": full }));
+ let saved = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap();
+ let block = &ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }))["paragraphs"][0];
+ assert_eq!(block["editor_text"], "One\nTwo", "{name}: {block}");
+ ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "width": 200.0 }));
+ let wide_name = format!("wide-{name}");
+ ok(&mut a, "doc_save", json!({ "doc": saved, "path": wide_name, "full": full }));
+ let wide = ok(&mut a, "doc_open", json!({ "path": wide_name }))["doc"].as_u64().unwrap();
+ let block = &ok(&mut a, "text_paragraphs", json!({ "doc": wide, "page": 1 }))["paragraphs"][0];
+ assert_eq!(block["editor_text"], "One Two", "{name}: the second typed space was lost: {block}");
+ }
+}
+
+fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec {
+ ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|p| p["text"].as_str().unwrap().to_string())
+ .collect()
+}
+
+/// The reported edits, through open / text_paragraphs / text_edit / save / reopen / text_extract.
+/// Covers an explicit TL, the three ways a file moves rows without TL, a blank line written with
+/// Td, a two-row file that must stay split, and a partial rewrite that adds a row.
+#[test]
+fn paragraph_grouping_survives_save_and_reopen_through_tools() {
+ let dir = workdir("paragraph-grouping");
+ let mut a = auto(&dir);
+ for step in [27.6_f64, 28.8] {
+ let y2 = 700.0 - step;
+ let y3 = 700.0 - 2.0 * step;
+ let bodies = [
+ format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"),
+ format!(
+ "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y2} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y3} Tm (Three) Tj ET"
+ ),
+ format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {y2} Td (Two) Tj ET BT /F1 12 Tf 72 {y3} Td (Three) Tj ET"),
+ ];
+ for (i, body) in bodies.iter().enumerate() {
+ let path = format!("step-{step}-{i}.pdf");
+ std::fs::write(dir.join(&path), helvetica_page(body)).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
+ let paras = paragraph_texts(&mut a, doc);
+ assert_eq!(paras.len(), 1, "{path}: {paras:?}");
+ assert!(paras[0].contains("One") && paras[0].contains("Two") && paras[0].contains("Three"), "{path}: {paras:?}");
+ let extracted = page_text(&mut a, doc);
+ assert!(extracted[0].contains("One") && extracted[0].contains("Three"), "{path}: {extracted:?}");
+ }
+ }
+
+ // One gap and no third row: still two paragraphs.
+ let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - 27.6);
+ std::fs::write(dir.join("pair.pdf"), helvetica_page(&pair)).unwrap();
+ let pair_doc = ok(&mut a, "doc_open", json!({ "path": "pair.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, pair_doc).len(), 2);
+
+ // Blank line written with Td, not TL. The inner step repeats; the break does not.
+ let inner = 14.4_f64;
+ let blank = format!(
+ "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \
+ BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET",
+ 700.0 - inner,
+ 700.0 - 3.0 * inner,
+ 700.0 - 4.0 * inner
+ );
+ std::fs::write(dir.join("blank.pdf"), helvetica_page(&blank)).unwrap();
+ let blank_doc = ok(&mut a, "doc_open", json!({ "path": "blank.pdf" }))["doc"].as_u64().unwrap();
+ let blank_paras = paragraph_texts(&mut a, blank_doc);
+ assert_eq!(blank_paras.len(), 2, "{blank_paras:?}");
+ assert!(blank_paras[0].contains("One") && blank_paras[0].contains("Two"), "{blank_paras:?}");
+ assert!(blank_paras[1].contains("Neighbour") && blank_paras[1].contains("Line"), "{blank_paras:?}");
+
+ // The first row is unchanged. Save incremental, reopen, edit again, save in full, reopen.
+ let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
+ BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET";
+ std::fs::write(dir.join("partial.pdf"), helvetica_page(partial)).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
+ let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "partial.pdf" }));
+ assert_eq!(incremental["incremental"], true, "{incremental}");
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays."]);
+ ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
+ let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "partial-full.pdf", "full": true }));
+ assert_eq!(full["incremental"], false, "{full}");
+ let again = ok(&mut a, "doc_open", json!({ "path": "partial-full.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays."]);
+ let extracted = page_text(&mut a, again);
+ assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}");
+}
+
+/// Editing the only paragraph on the page, while leaving its second row in the source, stays
+/// one paragraph through save and reopen. A neighbour in a different marked region stays in
+/// its own paragraph.
+#[test]
+fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() {
+ let dir = workdir("paragraph-tail");
+ let mut a = auto(&dir);
+ let only = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET";
+ std::fs::write(dir.join("only.pdf"), helvetica_page(only)).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row."]);
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
+ assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row."]);
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "only.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row."]);
+ ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
+ assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two."]);
+
+ let marked = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \
+ /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC";
+ std::fs::write(dir.join("marked.pdf"), helvetica_page(marked)).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
+ assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row.", "Neighbor stays."]);
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row.", "Neighbor stays."]);
+ ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
+ assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]);
+}
+
+/// Both ends of one text object, through open / text_edit / save / reopen / text_extract.
+/// Editing the last paragraph must not take the one before it, and editing the first must
+/// not take the one after it. Incremental and full saves.
+#[test]
+fn a_shared_text_object_keeps_both_paragraphs_through_tools() {
+ let dir = workdir("shared-text-object");
+ let mut a = auto(&dir);
+ let body =
+ "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET";
+ std::fs::write(dir.join("last.pdf"), helvetica_page(body)).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."]);
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nChanged." }));
+ assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
+ let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "last.pdf" }));
+ assert_eq!(incremental["incremental"], true, "{incremental}");
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
+ ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nEdited." }));
+ let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "last-full.pdf", "full": true }));
+ assert_eq!(full["incremental"], false, "{full}");
+ let again = ok(&mut a, "doc_open", json!({ "path": "last-full.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]);
+ let extracted = page_text(&mut a, again);
+ assert!(extracted[0].contains("Alpha first row.") && extracted[0].contains("Edited."), "{extracted:?}");
+
+ std::fs::write(dir.join("first.pdf"), helvetica_page(body)).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "first.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays. Neighbor next."]);
+ ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
+ ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "first-full.pdf", "full": true }));
+ let again = ok(&mut a, "doc_open", json!({ "path": "first-full.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays. Neighbor next."]);
+ let extracted = page_text(&mut a, again);
+ assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}");
+}
+
+/// Unambiguous separated one-pixel rules test the requested scale independently of the
+/// conservative local-tone guard. Ragged text can legitimately be retained by that guard.
+fn simple_rule_scan_drawn(content: &[u8]) -> Vec {
+ use pdfcraft_cos::{Document, Object, SaveOptions, Stream, write_full};
+ let mut doc = Document::open(std::sync::Arc::new(scan_drawn(content))).unwrap();
+ for number in doc.object_numbers() {
+ let reference = pdfcraft_cos::ObjRef::new(number, doc.generation(number));
+ let object = doc.get(reference);
+ let Object::Stream(image) = &*object else { continue };
+ if image.dict.name(b"Subtype") != Some(b"Image") {
+ continue;
+ }
+ let mut data = vec![0xFF; 125 * 400];
+ for y in 0..400 {
+ data[y * 125] &= 0x7F;
+ data[y * 125 + 124] &= 0xFE;
+ }
+ doc.set(reference, Object::Stream(Stream::flate(image.dict.clone(), &data)));
+ }
+ write_full(&doc, &SaveOptions::default()).unwrap()
+}
diff --git a/crates/automation/tests/it/backdrop.rs b/crates/automation/tests/it/backdrop.rs
new file mode 100644
index 0000000000..00570b260c
--- /dev/null
+++ b/crates/automation/tests/it/backdrop.rs
@@ -0,0 +1,269 @@
+//! Add Background on pages that paint their own backdrop, end to end through the tools: what
+//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back.
+
+use std::path::PathBuf;
+
+use pdfcraft_automation::{Automation, Content};
+use serde_json::{Value, json};
+
+/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write),
+/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture).
+fn fixture() -> Vec {
+ let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET";
+ let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")];
+ let mut objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(),
+ "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream"
+ .into(),
+ ];
+ for (i, c) in contents.iter().enumerate() {
+ objs.push(format!(
+ "<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>",
+ 5 + 2 * i
+ ));
+ objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len()));
+ }
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offs = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offs.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let x = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offs {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+fn workdir() -> PathBuf {
+ named_workdir("main")
+}
+
+/// A folder of its own per test: the tests of this binary run at the same time.
+fn named_workdir(name: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}-{name}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("a.pdf"), fixture()).unwrap();
+ dir
+}
+
+fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
+ match a.call(tool, &args) {
+ Ok(mut c) => match c.remove(0) {
+ Content::Json(v) => v,
+ other => panic!("{tool}: expected JSON, got {other:?}"),
+ },
+ Err(e) => panic!("{tool} {args}: {e}"),
+ }
+}
+
+/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi.
+fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] {
+ let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
+ let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") };
+ let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
+ let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
+ let info = reader.next_frame(&mut pixels).unwrap();
+ let channels = info.color_type.samples();
+ let at = (10 * *width as usize + 180) * channels;
+ [pixels[at], pixels[at + 1], pixels[at + 2]]
+}
+
+const RED: [u8; 3] = [255, 0, 0];
+const WHITE: [u8; 3] = [255, 255, 255];
+
+#[test]
+fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() {
+ let dir = workdir();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(
+ ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"],
+ json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }])
+ );
+
+ // Not replacing: the page's own white hides the background, as it always did.
+ ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false }));
+ assert_eq!(blank_spot(&mut a, doc, 1), WHITE);
+ assert_eq!(blank_spot(&mut a, doc, 2), RED);
+ ok(&mut a, "edit_undo", json!({ "doc": doc }));
+
+ // The default replaces it: red on both pages; the scan still hides it.
+ ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
+ assert_eq!(blank_spot(&mut a, doc, 1), RED);
+ assert_eq!(blank_spot(&mut a, doc, 2), RED);
+ assert_ne!(blank_spot(&mut a, doc, 3), RED);
+ assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }]));
+
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
+ let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen");
+ ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" }));
+ assert_eq!(blank_spot(&mut a, saved, 1), WHITE);
+ assert_eq!(
+ ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"],
+ json!([{ "page": 1, "backdrop": "fill" }]),
+ "the backdrop is back"
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// One 200 × 100 pt page on a white fill of its own: a line to redact, invisible (`3 Tr`) text
+/// that Remove Hidden Information removes, and a line that stays (`0 Tr` again: the render mode
+/// outlives `ET`). With `tag`, the content is a
+/// backdrop copy carrying that tag, under a background.
+fn secret_pdf(tag: Option<&str>) -> Vec {
+ let fill = if tag.is_some() { "n" } else { "f" };
+ let content = format!(
+ "1 1 1 rg 0 0 200 100 re {fill} 0 g BT /F1 12 Tf 10 70 Td (SECRET-LINE) Tj ET BT 3 Tr /F1 12 Tf 10 45 Td (HIDDEN-LINE) Tj ET BT 0 Tr /F1 12 Tf 10 20 Td (KEEP) Tj ET"
+ );
+ let bg = "q 1 0 0 rg 0 0 200 100 re f Q";
+ let (contents, extra) = match tag {
+ Some(t) => (
+ "[5 0 R 6 0 R]",
+ vec![
+ format!("<< /PCMark /Background /Length {} >>\nstream\n{bg}\nendstream", bg.len()),
+ format!("<< /PCBackdrop {t} /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ ],
+ ),
+ None => ("[5 0 R]", vec![format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())]),
+ };
+ let mut objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 100] >>".into(),
+ format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /Font << /F1 4 0 R >> >> >>"),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
+ ];
+ objs.extend(extra);
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offs = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offs.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let x = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offs {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+/// Every stream of a saved file, decoded, joined: what anyone reading the file can recover.
+fn every_stream(path: &std::path::Path) -> String {
+ let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
+ doc.scan_objects()
+ .filter_map(|(_, o)| match &*o {
+ pdfcraft_cos::Object::Stream(s) => Some(String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned()),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n")
+}
+
+/// Text a redaction or Remove Hidden Information took off a page whose backdrop a background
+/// replaced is gone from the saved file. The backdrop copy's tag used to keep a reference to the
+/// page's original content stream, and with it the removed text. Removing the background
+/// afterwards still turns the page's white fill back on.
+#[test]
+fn removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop() {
+ for (remove, gone) in [
+ (vec![("redact_mark", json!({ "find": "SECRET-LINE" })), ("redact_apply", json!({}))], "SECRET-LINE"),
+ (vec![("doc_remove_hidden", json!({ "categories": ["hidden-text"] }))], "HIDDEN-LINE"),
+ ] {
+ let dir = named_workdir(gone);
+ std::fs::write(dir.join("s.pdf"), secret_pdf(None)).unwrap();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "s.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
+ for (tool, args) in &remove {
+ let mut args = args.clone();
+ args["doc"] = json!(doc);
+ ok(&mut a, tool, args);
+ }
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
+ let file = every_stream(&dir.join("out.pdf"));
+ assert!(file.contains("(KEEP)"), "{gone}: the page's other text is still there: {file}");
+ assert!(!file.contains(gone), "{gone} is still in the file: {file}");
+
+ let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
+ let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" }));
+ assert!(removed.get("warnings").is_none(), "{gone}: {removed}");
+ assert_eq!(
+ ok(&mut a, "doc_backdrops", json!({ "doc": saved }))["pages"],
+ json!([{ "page": 1, "backdrop": "fill" }]),
+ "{gone}: the fill is back"
+ );
+ assert_eq!(blank_spot(&mut a, saved, 1), WHITE);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
+
+/// When the backdrop can't be turned back on safely, Remove background says which pages keep it
+/// off.
+#[test]
+fn remove_background_warns_about_pages_whose_own_backdrop_stays_off() {
+ let dir = named_workdir("left-off");
+ // A tag whose hash doesn't match the bytes before the fill: they changed since.
+ let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32));
+ std::fs::write(dir.join("t.pdf"), secret_pdf(Some(&tag))).unwrap();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "t.pdf" }))["doc"].as_u64().unwrap();
+ let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "background" }));
+ let warnings = removed["warnings"].as_array().unwrap_or_else(|| panic!("{removed}"));
+ assert_eq!(warnings.len(), 1, "{removed}");
+ assert!(warnings[0].as_str().unwrap().starts_with("page 1 changed after the background was added"), "{removed}");
+ assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "the fill stays off");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// Two 200 × 100 pt pages whose full-page `1 g` fill is not a plain white backdrop (#2815
+/// review): page 1 paints it half transparent, through a `gs` that sets `ca` and a later one
+/// that only sets the line width; page 2 sends it through a transfer function that maps 1 to 0.
+fn graphics_state_pdf() -> Vec {
+ let invert = "<< /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>";
+ let resources = format!("<< /ExtGState << /Transparent << /ca 0.5 >> /LineOnly << /LW 2 >> /Invert << /TR {invert} >> >> >>");
+ let contents = ["/Transparent gs /LineOnly gs 1 g 0 0 200 100 re f", "/Invert gs 1 g 0 0 200 100 re f"];
+ let mut objs: Vec =
+ vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 200 100] >>".into()];
+ for (i, c) in contents.iter().enumerate() {
+ objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 4 + 2 * i));
+ objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len()));
+ }
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offs = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offs.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let x = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offs {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+/// #2815 review: a translucent fill (its `ca` left in force by a later `gs`) and a fill through
+/// a transfer function are not backdrops, so a red background goes under them and the
+/// translucent white still lightens it.
+#[test]
+fn fills_the_graphics_state_changes_are_not_replaced() {
+ let dir = named_workdir("graphics-state");
+ std::fs::write(dir.join("gs.pdf"), graphics_state_pdf()).unwrap();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "gs.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "neither fill is a backdrop");
+ ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
+ let [r, g, b] = blank_spot(&mut a, doc, 1);
+ assert_eq!(r, 255, "{:?}", [r, g, b]);
+ assert!((100..=160).contains(&g) && (100..=160).contains(&b), "half-transparent white over red, not red: {:?}", [r, g, b]);
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/automation/tests/it/form_bulk.rs b/crates/automation/tests/it/form_bulk.rs
index 8b8904c974..4296cbc891 100644
--- a/crates/automation/tests/it/form_bulk.rs
+++ b/crates/automation/tests/it/form_bulk.rs
@@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}
+
+#[test]
+fn button_export_edit_is_one_undo_step_and_survives_save() {
+ let (mut a, id, dir) = setup("button-export");
+ tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]}));
+ tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}}));
+ let before = bytes(&a, id);
+ call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1}));
+ let f = tool(&mut a, "form_fields", json!({"doc": id}));
+ let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
+ assert_eq!(check["export_values"], json!(["Accepted"]));
+ assert_eq!(check["value"], true);
+ tool(&mut a, "edit_undo", json!({"doc": id}));
+ assert_eq!(bytes(&a, id), before);
+ tool(&mut a, "edit_redo", json!({"doc": id}));
+ tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
+ let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
+ let f = tool(&mut a, "form_fields", json!({"doc": reopened}));
+ let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
+ assert_eq!(check["export_values"], json!(["Accepted"]));
+ assert_eq!(check["value"], true);
+ std::fs::remove_dir_all(dir).unwrap();
+}
diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs
index d94aa9f7c4..d36dbe93a9 100644
--- a/crates/automation/tests/it/main.rs
+++ b/crates/automation/tests/it/main.rs
@@ -3,8 +3,12 @@
mod attachment_key;
mod automation;
mod automation_roots;
+mod backdrop;
mod batch;
mod button_caption;
mod form_bulk;
mod hostile_args;
mod optimize_content_boundaries;
+mod redact_after_stray_q_form;
+mod redact_text_after_q;
+mod render_warnings;
diff --git a/crates/automation/tests/it/redact_after_stray_q_form.rs b/crates/automation/tests/it/redact_after_stray_q_form.rs
new file mode 100644
index 0000000000..a91f872750
--- /dev/null
+++ b/crates/automation/tests/it/redact_after_stray_q_form.rs
@@ -0,0 +1,67 @@
+//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes
+//! come from the renderer's text and redaction measures the content itself, so the two must agree
+//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68).
+
+use pdfcraft_automation::{Automation, Content};
+use serde_json::{Value, json};
+
+const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \
+ BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q";
+const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q";
+
+fn pdf() -> Vec {
+ let objects = [
+ "<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
+ /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>"
+ .to_owned(),
+ format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()),
+ format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, object) in objects.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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 << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
+ out
+}
+
+fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
+ match a.call(tool, &args) {
+ Ok(mut c) => match c.remove(0) {
+ Content::Json(v) => v,
+ other => panic!("{tool}: expected JSON, got {other:?}"),
+ },
+ Err(e) => panic!("{tool} {args}: {e}"),
+ }
+}
+
+fn text(a: &mut Automation, doc: &Value) -> String {
+ ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
+}
+
+#[test]
+fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("in.pdf"), pdf()).unwrap();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
+ assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1);
+ ok(&mut a, "redact_apply", json!({ "doc": doc }));
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
+ let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
+ let after = text(&mut a, &saved);
+ assert!(!after.contains("SECRET"), "{after:?}");
+ assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}");
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/automation/tests/it/redact_text_after_q.rs b/crates/automation/tests/it/redact_text_after_q.rs
new file mode 100644
index 0000000000..fceaffb872
--- /dev/null
+++ b/crates/automation/tests/it/redact_text_after_q.rs
@@ -0,0 +1,77 @@
+//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text
+//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so
+//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the
+//! renderer's text, which places it there; redaction measures the content itself, and while it
+//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything.
+use pdfcraft_automation::{Automation, Content};
+use serde_json::{Value, json};
+
+/// The content, the text that stays after SECRET is redacted, and the text that goes with it.
+const CASES: [(&str, &str, &str); 3] = [
+ // A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over
+ // the B and the B goes with it.
+ ("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"),
+ // A move inside `q`/`Q` is undone.
+ ("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"),
+ // The text line matrix is restored too: `T*` starts the next line from the line before `q`.
+ ("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"),
+];
+
+fn pdf(content: &str) -> Vec {
+ let objects = [
+ "<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
+ /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \
+ /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>"
+ .to_owned(),
+ format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ ];
+ let mut out = b"%PDF-2.0\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, object) in objects.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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 << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
+ out
+}
+
+fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
+ match a.call(tool, &args) {
+ Ok(mut c) => match c.remove(0) {
+ Content::Json(v) => v,
+ other => panic!("{tool}: expected JSON, got {other:?}"),
+ },
+ Err(e) => panic!("{tool} {args}: {e}"),
+ }
+}
+
+fn text(a: &mut Automation, doc: &Value) -> String {
+ ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
+}
+
+#[test]
+fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() {
+ for (case, (content, kept, gone)) in CASES.iter().enumerate() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
+ assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}");
+ ok(&mut a, "redact_apply", json!({ "doc": doc }));
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
+ let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
+ let after = text(&mut a, &saved);
+ assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}");
+ assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs
new file mode 100644
index 0000000000..ff50c11e16
--- /dev/null
+++ b/crates/automation/tests/it/render_warnings.rs
@@ -0,0 +1,119 @@
+//! `page_render` reports the renderer's warnings next to the PNG, so a headless caller can tell
+//! that a picture is incomplete or approximate, as `pdfcraft-cli render` can (#4340).
+
+use std::path::PathBuf;
+
+use pdfcraft_automation::{Automation, Content};
+use serde_json::{Value, json};
+
+/// The warning a colour that falls back gives, worded as `pdfcraft-cli render` prints it.
+const FALLBACK: &str = "a colour that could not be computed was painted in a fallback colour";
+
+/// A one-page 200 × 200 pt PDF that draws `content`, with the colour space `CS1` defined as
+/// `[/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R]` and object 5 a calculator function `program`.
+fn devicen_pdf(content: &str, program: &str) -> Vec {
+ format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /CS1 [/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R] >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ program.len()
+ )
+ .into_bytes()
+}
+
+/// A page whose colour falls back: the tint transform pops more than it is given, so it can't be
+/// evaluated (the renderer's own test of the warning uses the same function).
+fn fallback_pdf() -> Vec {
+ devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop pop }")
+}
+
+/// The same page with a tint transform that works: nothing to warn about.
+fn clean_pdf() -> Vec {
+ devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop 0 0 0 }")
+}
+
+/// A fresh folder holding `fallback.pdf` and `clean.pdf`, and an automation rooted there.
+fn setup(test: &str) -> (PathBuf, Automation) {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-{test}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("fallback.pdf"), fallback_pdf()).unwrap();
+ std::fs::write(dir.join("clean.pdf"), clean_pdf()).unwrap();
+ let a = Automation::new().with_root(&dir).unwrap();
+ (dir, a)
+}
+
+fn open(a: &mut Automation, path: &str) -> u64 {
+ match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) {
+ Content::Json(v) => v["doc"].as_u64().unwrap(),
+ other => panic!("expected JSON: {other:?}"),
+ }
+}
+
+#[test]
+fn page_render_lists_the_warnings_after_the_png() {
+ let (dir, mut a) = setup("fallback");
+ let doc = open(&mut a, "fallback.pdf");
+ let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
+ assert_eq!(out.len(), 2, "the PNG and the warnings: {out:?}");
+ assert!(matches!(out[0], Content::Png { width: 200, height: 200, .. }), "the image comes first, as before");
+ let Content::Json(report) = &out[1] else { panic!("expected the warnings as JSON: {:?}", out[1]) };
+ assert_eq!(report["page"], 1, "the page is 1-based, like the argument");
+ assert_eq!(report["warnings"], json!([FALLBACK]));
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn render_preview_lists_the_warnings_after_the_png_too() {
+ let (dir, mut a) = setup("preview");
+ let doc = open(&mut a, "fallback.pdf");
+ let out = a.call("render_preview", &json!({ "doc": doc, "max_side": 64 })).unwrap();
+ assert!(matches!(out.first(), Some(Content::Png { width: 64, height: 64, .. })), "{out:?}");
+ let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
+ assert_eq!(report["page"], 1, "the page defaults to the first");
+ assert_eq!(report["warnings"], json!([FALLBACK]));
+ let clean = open(&mut a, "clean.pdf");
+ assert_eq!(a.call("render_preview", &json!({ "doc": clean })).unwrap().len(), 1, "a clean page gives the PNG alone");
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn a_clean_page_renders_to_the_png_alone() {
+ let (dir, mut a) = setup("clean");
+ let fallback = open(&mut a, "fallback.pdf");
+ let clean = open(&mut a, "clean.pdf");
+ // The warning of one render doesn't carry over to the next, nor to another document.
+ a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap();
+ let out = a.call("page_render", &json!({ "doc": clean, "page": 1, "dpi": 72 })).unwrap();
+ assert_eq!(out.len(), 1, "no warnings, no extra part: {out:?}");
+ assert!(matches!(out[0], Content::Png { .. }));
+ let again = a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap();
+ assert_eq!(again.len(), 2, "a cached renderer still reports the page's warning: {again:?}");
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+/// Over MCP the warnings are a text block after the image and the call's `structuredContent`.
+#[cfg(feature = "mcp")]
+#[test]
+fn mcp_page_render_carries_the_warnings_as_structured_content() {
+ use pdfcraft_automation::mcp::McpServer;
+ let (dir, a) = setup("mcp");
+ let mut s = McpServer::new(a);
+ let mut rpc = |id: u64, method: &str, params: Value| -> Value {
+ let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
+ serde_json::from_str(&s.handle_line(&line).expect("a reply")).unwrap()
+ };
+ rpc(1, "initialize", json!({}));
+ rpc(2, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "fallback.pdf" } }));
+ let reply = rpc(3, "tools/call", json!({ "name": "page_render", "arguments": { "doc": 1, "page": 1 } }));
+ let result = &reply["result"];
+ assert_eq!(result["isError"], false, "{reply}");
+ assert_eq!(result["content"][0]["type"], "image");
+ assert_eq!(result["content"][1]["type"], "text");
+ assert_eq!(result["structuredContent"]["warnings"], json!([FALLBACK]));
+ let _ = std::fs::remove_dir_all(dir);
+}
diff --git a/crates/cos/README.md b/crates/cos/README.md
index ab382219d5..2cf3342954 100644
--- a/crates/cos/README.md
+++ b/crates/cos/README.md
@@ -24,6 +24,9 @@ let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc, resolving first
let data = stream.decoded()?; // filters applied on demand
+let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too
+let px = doc.decoded_image(&stream)?; // an image's samples, a CCITT fax image's too
+let fax = doc.fax_params(&stream); // a CCITT fax image's /DecodeParms, Table 11 defaults
doc.set(r, Object::Dict(d)); // edit: lands in the overlay
let r = doc.add(obj); // append a new object
@@ -87,5 +90,6 @@ recognisable in a diff and byte-stable when re-serialized.
- Linearized output, Fast Web View (`core.linearization`, M11).
- Structural validation against the Arlington model (`core.arlington-validation`), which will live
in the reserved `arlington` crate.
-- Image codecs. `pdfcraft-filters` carries the non-image filters; DCT, JPX, JBIG2 and CCITT are
- still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
+- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding
+ (`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer
+ (`core.image-filters`, M2.6).
diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs
index 0fac1591a9..0d9b84b749 100644
--- a/crates/cos/src/document.rs
+++ b/crates/cos/src/document.rs
@@ -5,11 +5,12 @@
//! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites
//! the file (full save).
+use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, Mutex, Weak};
use crate::CosError;
-use crate::object::{Dict, ObjRef, Object};
+use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream};
use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
mod objects;
diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs
index 2ff729f1fb..4f05d03ae0 100644
--- a/crates/cos/src/document/objects.rs
+++ b/crates/cos/src/document/objects.rs
@@ -1,7 +1,7 @@
use super::reader::find;
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes};
use super::xref::{Revision, XrefEntry};
-use super::*;
+use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared};
impl Document {
pub fn bytes(&self) -> &Arc> {
@@ -158,11 +158,148 @@ impl Document {
}
}
+ /// `s` with the `/DecodeParms` its filters use made direct, so [`Stream::decoded`] reads them:
+ /// an indirect dictionary resolved, and of an array (itself perhaps indirect), the entries at
+ /// the filters' places (`/Crypt` counts, ISO 32000-2 §7.4.1) resolved, the rest dropped. Of
+ /// each dictionary only the entries the decoders read are kept, numbers and flags given by
+ /// reference (`/Predictor 7 0 R`) resolved; `/JBIG2Globals` stays a reference. A `/Crypt`
+ /// entry, which no decoder reads ([`Stream::filters`] leaves it out), is null. `s` itself
+ /// when none of those is indirect, or when the other filters are more than any decoder takes
+ /// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a
+ /// large object copied once for each key and filter.
+ pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
+ let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) };
+ let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt"));
+ let filters: Vec = match s.dict.get(b"Filter") {
+ Some(Object::Array(a)) => a.iter().map(crypt).collect(),
+ Some(f) => vec![crypt(f)],
+ None => Vec::new(),
+ };
+ if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN {
+ return Cow::Borrowed(s);
+ }
+ let mut changed = matches!(p, Object::Ref(_));
+ // Its numbers and flags kept, resolved; anything else is no filter's parameters (null).
+ let one = |e: &Object, changed: &mut bool| -> Object {
+ *changed |= matches!(e, Object::Ref(_));
+ let Object::Dict(d) = &*self.held(e) else { return Object::Null };
+ let mut made = Dict::new();
+ for key in DECODE_PARMS_KEYS {
+ let Some(v) = d.get(key) else { continue };
+ *changed |= matches!(v, Object::Ref(_));
+ let v = self.held(v);
+ if matches!(*v, Object::Int(_) | Object::Real(_) | Object::Bool(_)) {
+ made.set(key.to_vec(), (*v).clone());
+ }
+ }
+ if let Some(g @ Object::Ref(_)) = d.get(b"JBIG2Globals") {
+ made.set(b"JBIG2Globals".to_vec(), g.clone());
+ }
+ Object::Dict(made)
+ };
+ let made = match &*self.held(p) {
+ Object::Array(a) => {
+ Object::Array(a.iter().zip(&filters).map(|(e, crypt)| if *crypt { Object::Null } else { one(e, &mut changed) }).collect())
+ }
+ _ => one(p, &mut changed),
+ };
+ if !changed {
+ return Cow::Borrowed(s);
+ }
+ let mut s = s.clone();
+ s.dict.set(b"DecodeParms".to_vec(), made);
+ Cow::Owned(s)
+ }
+
+ /// `o`, or the object it refers to, without copying either.
+ fn held<'o>(&self, o: &'o Object) -> Held<'o> {
+ match o {
+ Object::Ref(_) => Held::Resolved(self.resolve(o)),
+ o => Held::Direct(o),
+ }
+ }
+
/// Resolve and return a dictionary (a stream's dictionary counts).
pub fn dict(&self, o: &Object) -> Option {
self.resolve(o).as_dict().cloned()
}
+ /// The CCITT parameters of image `s` when its last filter is `CCITTFaxDecode`: its
+ /// `/DecodeParms` (resolved; the dictionary of a lone filter, or its entry in an array), with the
+ /// defaults of ISO 32000-2 Table 11 for what that leaves out. `None` for another image, and for
+ /// one whose `/Filter` isn't a name or an array of names.
+ pub fn fax_params(&self, s: &Stream) -> Option {
+ let chain = s.readable_filters().ok()?;
+ if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) {
+ return None;
+ }
+ // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the
+ // last filter but a /Crypt, which `filters` leaves out wherever it stands.
+ let index = match s.dict.get(b"Filter") {
+ Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0),
+ _ => 0,
+ };
+ let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() {
+ Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)),
+ Some(p) if index == 0 => p.as_dict().cloned(),
+ _ => None,
+ }
+ .unwrap_or_default();
+ let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default);
+ let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() {
+ Some(Object::Bool(b)) => *b,
+ _ => default,
+ };
+ let d = pdfcraft_filters::CcittParams::default();
+ Some(pdfcraft_filters::CcittParams {
+ k: int(b"K", d.k),
+ columns: int(b"Columns", d.columns),
+ rows: int(b"Rows", d.rows),
+ end_of_line: flag(b"EndOfLine", d.end_of_line),
+ encoded_byte_align: flag(b"EncodedByteAlign", d.encoded_byte_align),
+ end_of_block: flag(b"EndOfBlock", d.end_of_block),
+ black_is_1: flag(b"BlackIs1", d.black_is_1),
+ })
+ }
+
+ /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a
+ /// CCITT fax image (its last filter), through the filters before it and then
+ /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode`
+ /// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter
+ /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to
+ /// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged
+ /// data gives fewer, so a caller checks it has the rows it needs.
+ pub fn decoded_image(&self, s: &Stream) -> Result, CosError> {
+ self.decoded_image_within(s, MAX_DECODED)
+ }
+
+ /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than
+ /// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than
+ /// `max` bytes.
+ pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> {
+ let max = max.min(MAX_DECODED);
+ let s = &*self.with_direct_parms(s);
+ let Some(p) = self.fax_params(s) else { return s.decoded_within(max) };
+ // As many rows as the image has: a /Rows below /Height would cut the picture short, and
+ // rows past /Height aren't drawn.
+ let p = pdfcraft_filters::CcittParams { rows: 0, ..p };
+ if s.dict.int(b"Width") != Some(p.columns) {
+ return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into()));
+ }
+ let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0);
+ let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1);
+ let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height);
+ // The filters before it, such as ASCII85Decode, are undone first.
+ let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?;
+ let data = match chain.split_last() {
+ Some((_, before)) if !before.is_empty() => {
+ Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0)
+ }
+ _ => Cow::Borrowed(&s.raw[..]),
+ };
+ pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string()))
+ }
+
pub fn try_get(&self, num: u32) -> Result, CosError> {
match self.overlay.get(&num) {
Some(Slot::Set(_, o)) => return Ok(o.clone()),
@@ -570,3 +707,40 @@ impl Document {
matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
}
}
+
+/// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and
+/// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by
+/// reference, which [`Dict::int`] doesn't follow.
+const DECODE_PARMS_KEYS: [&[u8]; 13] = [
+ b"Predictor",
+ b"Colors",
+ b"BitsPerComponent",
+ b"Columns",
+ b"EarlyChange",
+ b"K",
+ b"Rows",
+ b"EndOfLine",
+ b"EncodedByteAlign",
+ b"EndOfBlock",
+ b"BlackIs1",
+ b"DamagedRowsBeforeError",
+ b"ColorTransform",
+];
+
+/// An object read in place, or the object a reference names as the document holds it: either way
+/// nothing is copied ([`Document::held`]).
+enum Held<'o> {
+ Direct(&'o Object),
+ Resolved(Arc),
+}
+
+impl std::ops::Deref for Held<'_> {
+ type Target = Object;
+
+ fn deref(&self) -> &Object {
+ match self {
+ Held::Direct(o) => o,
+ Held::Resolved(o) => o,
+ }
+ }
+}
diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs
index c90d5c0906..57d697ed66 100644
--- a/crates/cos/src/document/open.rs
+++ b/crates/cos/src/document/open.rs
@@ -1,7 +1,7 @@
-use super::reader::{find, generated_id};
+use super::reader::find;
use super::state::Credentials;
use super::xref::XrefIndex;
-use super::*;
+use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object};
impl Document {
/// A new document with an empty page tree (for split / extract / combine). It has no
@@ -271,8 +271,7 @@ impl Document {
let id0 = match self.trailer.get(b"ID") {
Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(),
_ => {
- let mut id = generated_id(¶ms.seed);
- id.truncate(16);
+ let id = pdfcraft_crypt::file_id(¶ms.seed);
let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true });
self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
id
diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs
index ead09c36e5..d21bf9403f 100644
--- a/crates/cos/src/document/reader.rs
+++ b/crates/cos/src/document/reader.rs
@@ -3,7 +3,7 @@ use super::state::SECTION_READS;
use super::xref::{
MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData,
};
-use super::*;
+use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared};
impl Document {
#[allow(clippy::type_complexity)]
@@ -818,18 +818,6 @@ pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option {
(from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle)
}
-/// A file identifier derived from entropy supplied by the caller.
-pub(super) fn generated_id(seed: &[u8; 32]) -> Vec {
- use std::hash::{Hash, Hasher};
- let mut out = Vec::new();
- for i in 0..2u8 {
- let mut h = std::collections::hash_map::DefaultHasher::new();
- (seed, i, b"pdfcraft id").hash(&mut h);
- out.extend_from_slice(&h.finish().to_be_bytes());
- }
- out
-}
-
/// The object number a classic xref subsection really starts at. A common producer bug heads the
/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so
/// every row would be read one number too high and object 1 as free. Such a subsection is read
diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs
index 30f193e4e6..b4323f982e 100644
--- a/crates/cos/src/document/state.rs
+++ b/crates/cos/src/document/state.rs
@@ -1,5 +1,5 @@
use super::xref::XrefEntry;
-use super::*;
+use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak};
thread_local! {
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs
index bf22f23d76..f5e36f9c83 100644
--- a/crates/cos/src/document/tests.rs
+++ b/crates/cos/src/document/tests.rs
@@ -1,5 +1,210 @@
use super::*;
+/// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read
+/// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are
+/// resolved, and nothing is for a chain longer than a decoder takes.
+#[test]
+fn decode_parms_given_by_reference_are_read_once_direct() {
+ let rows = [1u8, 2, 3, 4, 5, 6, 7, 8];
+ let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 4, early_change: 1 };
+ let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &rows).unwrap();
+ let mut parms = Dict::new();
+ parms.set(b"Predictor".to_vec(), Object::Int(12));
+ parms.set(b"Columns".to_vec(), Object::Int(4));
+ let mut doc = Document::new_empty();
+ let r = doc.add(Object::Dict(parms.clone()));
+ let array = doc.add(Object::Array(vec![Object::Ref(r)]));
+ let stream = |filters: usize, p: Object| {
+ let mut d = Dict::new();
+ d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"); filters]));
+ d.set(b"DecodeParms".to_vec(), p);
+ Stream::from_raw(d, coded.clone())
+ };
+ for p in [Object::Ref(r), Object::Array(vec![Object::Ref(r)]), Object::Ref(array)] {
+ let s = stream(1, p.clone());
+ assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is");
+ assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}");
+ }
+ // A number inside given by reference, which `Dict::int` doesn't follow: in the dictionary
+ // itself, and in an array's entry.
+ let twelve = doc.add(Object::Int(12));
+ let mut inner = parms.clone();
+ inner.set(b"Predictor".to_vec(), Object::Ref(twelve));
+ for p in [Object::Dict(inner.clone()), Object::Array(vec![Object::Dict(inner)])] {
+ let s = stream(1, p.clone());
+ assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is");
+ assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}");
+ }
+ // Only numbers and flags are copied: a key naming a big array, or an entry that is no
+ // dictionary, is left out (a file could name one big object from every key and filter).
+ let big = doc.add(Object::Array(vec![Object::Int(1); 100_000]));
+ let mut hostile = Dict::new();
+ hostile.set(b"Predictor".to_vec(), Object::Ref(big));
+ hostile.set(b"Columns".to_vec(), Object::Ref(big));
+ hostile.set(b"Colors".to_vec(), Object::Ref(twelve));
+ let entry = doc.add(Object::Dict(hostile));
+ let two = stream(2, Object::Array(vec![Object::Ref(entry), Object::Ref(big)]));
+ let made = doc.with_direct_parms(&two);
+ let made = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap();
+ let first = made[0].as_dict().unwrap();
+ assert_eq!((first.contains(b"Predictor"), first.contains(b"Columns"), first.int(b"Colors")), (false, false, Some(12)));
+ assert!(matches!(made[1], Object::Null), "{:?}", made[1]);
+ let direct = stream(1, Object::Dict(parms));
+ assert!(matches!(doc.with_direct_parms(&direct), Cow::Borrowed(_)), "nothing to resolve");
+ assert_eq!(direct.decoded().unwrap(), rows);
+ // Entries past the filters' are left out, unresolved; so is everything past the chain limit.
+ let many = stream(1, Object::Array(vec![Object::Ref(r); 1000]));
+ let made = doc.with_direct_parms(&many);
+ assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(1));
+ let long = stream(pdfcraft_filters::MAX_CHAIN + 1, Object::Array(vec![Object::Ref(r); 9]));
+ assert!(matches!(doc.with_direct_parms(&long), Cow::Borrowed(_)));
+ // A chain as long as a decoder takes is resolved, and a `/Crypt` in it doesn't count, as
+ // the decoders leave it out: ASCIIHex as often as the limit allows, then the predictor's
+ // Flate, its parameters by reference.
+ let hex = |b: &[u8]| b.iter().flat_map(|x| format!("{x:02X}").into_bytes()).chain(*b">").collect::>();
+ let wrapped = (1..pdfcraft_filters::MAX_CHAIN).fold(coded.clone(), |data, _| hex(&data));
+ for crypt in [false, true] {
+ let mut names = vec![Object::name("ASCIIHexDecode"); pdfcraft_filters::MAX_CHAIN - 1];
+ names.push(Object::name("FlateDecode"));
+ if crypt {
+ names.insert(0, Object::name("Crypt"));
+ }
+ let mut entries = vec![Object::Null; names.len() - 1];
+ entries.push(Object::Ref(r));
+ let mut d = Dict::new();
+ d.set(b"Filter".to_vec(), Object::Array(names));
+ d.set(b"DecodeParms".to_vec(), Object::Array(entries));
+ let s = Stream::from_raw(d, wrapped.clone());
+ assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "with /Crypt: {crypt}");
+ }
+}
+
+/// A CCITT fax image reads through its /DecodeParms, given directly, in an array or by
+/// reference (its numbers too), with /BlackIs1, and behind other filters; one whose /Columns
+/// aren't its /Width is refused, /Height rows are decoded whatever /Rows says, and other images
+/// decode through their filters.
+#[test]
+fn fax_images_decode_with_their_parameters() {
+ // 10 × 3: the first five samples of each row white, the rest black.
+ let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
+ let coded = pdfcraft_filters::encode_g4(&bits, 10, 3).unwrap();
+ let mut doc = Document::new_empty();
+ let mut parms = Dict::new();
+ parms.set(b"K".to_vec(), Object::Int(-1));
+ parms.set(b"Columns".to_vec(), Object::Int(10));
+ let indirect = doc.add(Object::Dict(parms.clone()));
+ let image = |doc: &Document, parms: Option, width: i64, height: i64| {
+ let mut d = Dict::new();
+ d.set(b"Width".to_vec(), Object::Int(width));
+ d.set(b"Height".to_vec(), Object::Int(height));
+ d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
+ if let Some(p) = parms {
+ d.set(b"DecodeParms".to_vec(), p);
+ }
+ doc.decoded_image(&Stream::from_raw(d, coded.clone()))
+ };
+ for p in [
+ Object::Dict(parms.clone()),
+ Object::Array(vec![Object::Dict(parms.clone())]),
+ Object::Ref(indirect),
+ Object::Array(vec![Object::Ref(indirect)]),
+ ] {
+ assert_eq!(image(&doc, Some(p.clone()), 10, 3).unwrap(), bits, "{p:?}");
+ }
+ let mut inverted = parms.clone();
+ inverted.set(b"BlackIs1".to_vec(), Object::Bool(true));
+ assert_eq!(
+ image(&doc, Some(Object::Dict(inverted)), 10, 3).unwrap(),
+ [0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000]
+ );
+ assert_eq!(image(&doc, Some(Object::Dict(parms.clone())), 10, 2).unwrap(), bits[..4], "at most /Height rows");
+ // Within a bound: no more rows than it holds.
+ let mut bounded = Dict::new();
+ bounded.set(b"Width".to_vec(), Object::Int(10));
+ bounded.set(b"Height".to_vec(), Object::Int(3));
+ bounded.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
+ bounded.set(b"DecodeParms".to_vec(), Object::Dict(parms.clone()));
+ assert_eq!(doc.decoded_image_within(&Stream::from_raw(bounded, coded.clone()), 5).unwrap(), bits[..4], "two rows of two bytes in five");
+ let mut by_reference = Dict::new();
+ by_reference.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1))));
+ by_reference.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(10))));
+ assert_eq!(image(&doc, Some(Object::Dict(by_reference)), 10, 3).unwrap(), bits, "/K and /Columns by reference");
+ let mut understated = parms.clone();
+ understated.set(b"Rows".to_vec(), Object::Int(1));
+ assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says");
+ // The parameters as the file gives them, /Rows included, and Table 11's defaults.
+ let mut d = Dict::new();
+ d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
+ d.set(b"DecodeParms".to_vec(), Object::Dict(understated));
+ let p = doc.fax_params(&Stream::from_raw(d.clone(), Vec::new())).unwrap();
+ assert_eq!((p.k, p.columns, p.rows, p.end_of_block, p.black_is_1), (-1, 10, 1, true, false));
+ d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
+ assert!(doc.fax_params(&Stream::from_raw(d, Vec::new())).is_none(), "not a fax image");
+ assert!(matches!(image(&doc, Some(Object::Dict(parms.clone())), 12, 3), Err(CosError::Filter(_))), "/Columns other than /Width");
+ assert!(matches!(image(&doc, None, 10, 3), Err(CosError::Filter(_))), "the default 1728 /Columns");
+ let mut plain = Dict::new();
+ plain.set(b"Filter".to_vec(), Object::name("FlateDecode"));
+ assert_eq!(doc.decoded_image(&Stream::from_raw(plain, pdfcraft_filters::encode_flate(&bits))).unwrap(), bits);
+
+ // Behind other filters, its parameters at its own place in the array (/Crypt counts).
+ let behind = |filters: &[Object], parms: &[Object], data: Vec| {
+ let mut d = Dict::new();
+ d.set(b"Width".to_vec(), Object::Int(10));
+ d.set(b"Height".to_vec(), Object::Int(3));
+ d.set(b"Filter".to_vec(), Object::Array(filters.to_vec()));
+ d.set(b"DecodeParms".to_vec(), Object::Array(parms.to_vec()));
+ doc.decoded_image(&Stream::from_raw(d, data))
+ };
+ let (fax, null, own) = (Object::name("CCITTFaxDecode"), Object::Null, Object::Dict(parms.clone()));
+ let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes();
+ let flated = pdfcraft_filters::encode_flate(&coded);
+ let ahx = [Object::name("AHx"), fax.clone()];
+ assert_eq!(behind(&ahx, &[null.clone(), Object::Ref(indirect)], hex.clone()).unwrap(), bits, "behind ASCIIHexDecode");
+ assert_eq!(
+ behind(&[Object::name("FlateDecode"), Object::name("CCF")], &[null.clone(), own.clone()], flated.clone()).unwrap(),
+ bits,
+ "behind Flate"
+ );
+ assert_eq!(behind(&[Object::name("Crypt"), fax.clone()], &[null.clone(), own.clone()], coded.clone()).unwrap(), bits, "after /Crypt");
+ // A /Crypt belongs first (§7.4.10); one after the fax filter leaves it its own entry.
+ assert_eq!(behind(&[fax.clone(), Object::name("Crypt")], &[own.clone(), null.clone()], coded.clone()).unwrap(), bits, "before /Crypt");
+ assert!(behind(&ahx, &[own.clone(), null.clone()], hex).is_err(), "the first entry is the other filter's");
+ assert!(behind(&[fax.clone(), Object::name("FlateDecode")], &[own.clone(), null.clone()], flated).is_err(), "CCITT, then Flate");
+ assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name");
+}
+
+#[test]
+fn a_missing_file_id_is_derived_from_the_encryption_seed() {
+ let mut doc = Document::new_empty();
+ let seed = [9u8; 32];
+ doc.set_encryption(&pdfcraft_crypt::NewEncryption {
+ algorithm: pdfcraft_crypt::Algorithm::Aes256,
+ user_password: "user",
+ owner_password: "owner",
+ permissions: -4,
+ encrypt_metadata: true,
+ seed,
+ })
+ .unwrap();
+ let id = doc.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone());
+ assert_eq!(id.as_deref(), Some(pdfcraft_crypt::file_id(&seed).as_slice()));
+
+ let mut kept = Document::new_empty();
+ let fixed = Object::String(crate::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true });
+ kept.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![fixed.clone(), fixed]));
+ kept.set_encryption(&pdfcraft_crypt::NewEncryption {
+ algorithm: pdfcraft_crypt::Algorithm::Aes256,
+ user_password: "user",
+ owner_password: "owner",
+ permissions: -4,
+ encrypt_metadata: true,
+ seed,
+ })
+ .unwrap();
+ let still = kept.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone());
+ assert_eq!(still.as_deref(), Some(b"0123456789abcdef".as_slice()));
+}
+
#[test]
fn a_read_state_matches_a_clone_and_no_changed_document() {
let mut doc = Document::new_empty();
diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs
index 96a782c1a9..02390ff7da 100644
--- a/crates/cos/src/document/xref.rs
+++ b/crates/cos/src/document/xref.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, BTreeMap, Lexer};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XrefEntry {
diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs
index c9b2ff9a36..17ee7844cf 100644
--- a/crates/cos/src/object.rs
+++ b/crates/cos/src/object.rs
@@ -261,7 +261,7 @@ impl Stream {
/// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an
/// array of names (an indirect reference, say): the data is not unfiltered, so it must not
/// read as decoded.
- fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> {
+ pub(crate) fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> {
let readable = match self.dict.get(b"Filter") {
None | Some(Object::Null | Object::Name(_)) => true,
Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()),
diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml
index fa649de47d..b41caa2b88 100644
--- a/crates/create/Cargo.toml
+++ b/crates/create/Cargo.toml
@@ -10,8 +10,13 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
+pdfcraft-filters = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-fonts = { workspace = true }
+# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
+pdfcraft-xfa = { workspace = true }
+# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images.
+hayro-jbig2.workspace = true
png = "0.18"
tiff = "0.11"
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
@@ -22,5 +27,8 @@ thiserror = { workspace = true }
encoding_rs = "0.8"
[dev-dependencies]
+# An independent CCITT decoder, to check Create PDF's Group 4 images.
+hayro-ccitt.workspace = true
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
hayro-syntax = { workspace = true }
+pdfcraft-filters = { workspace = true }
diff --git a/crates/create/README.md b/crates/create/README.md
index d1561a6333..d359eed9ae 100644
--- a/crates/create/README.md
+++ b/crates/create/README.md
@@ -6,7 +6,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
- `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution
(PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is
(`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate,
- with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG
+ with transparency as a soft mask. A picture of only pure black and white (a 1-bit PNG or TIFF,
+ eight-bit grey, a two-colour palette, RGB without a profile) is stored at one bit per pixel, as
+ CCITT Group 4 or Flate, whichever is smaller as stored (`pdfcraft_filters::encode_g4`). An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG
`iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with
the device space as `/Alternate`; images with the same profile share one profile object.
JPEG metadata scanning skips marker fill bytes and reads application segments after the
@@ -25,9 +27,20 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
+JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included,
+as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more
+pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the
+page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image
+exported opaque, as JPX and CCITT masks are.
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
Missing, null or malformed Decode arrays keep the default mapping.
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
The existing 16-bit path still uses each sample's high byte.
JPEG image Decode handling remains limited.
ICC transforms and calibrated color conversion remain outside this export path.
+CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::Document::decoded_image` with their
+`/DecodeParms` (`/Columns` has to be the image's width; up to `/Height` rows, whatever `/Rows` says), behind other filters such as
+`ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or
+ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine`
+says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or,
+at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported.
diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs
index 7828952edc..b12bd50ec2 100644
--- a/crates/create/src/extract.rs
+++ b/crates/create/src/extract.rs
@@ -1,7 +1,7 @@
//! 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
-//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are
+//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, separations) are
//! reported, never silently left out.
use std::collections::HashSet;
@@ -100,6 +100,116 @@ fn filters(s: &Stream) -> Vec> {
}
}
+/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given
+/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor
+/// given so would otherwise be lost), or, for a JBIG2 image
+/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples
+/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7).
+fn decoded(doc: &Document, s: &Stream) -> Result, String> {
+ match filters(s).as_slice() {
+ [f] if f == b"JBIG2Decode" => jbig2(doc, s),
+ _ => doc.decoded_image(s).map_err(|e| e.to_string()),
+ }
+}
+
+/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2).
+fn decode_parms(doc: &Document, s: &Stream) -> Dict {
+ let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() };
+ let p = match &*p {
+ Object::Array(a) => match a.first() {
+ Some(first) => doc.resolve(first),
+ None => return Dict::default(),
+ },
+ _ => p,
+ };
+ p.as_dict().cloned().unwrap_or_default()
+}
+
+/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder
+/// shares between pages, themselves coded, so far less than any stream may decode to
+/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image.
+const MAX_GLOBALS: usize = 32 << 20;
+
+/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller
+/// checked against the export's size limit, and within that limit itself: the decoder decodes the
+/// whole page, rows past the image's height too, so a small `/Height` over a far taller page
+/// would cost more than the limit allows. Rows past the image's height are dropped, and too few
+/// rows are refused by the caller. Globals that can't be read refuse the image, rather than
+/// decoding it without them. They are decoded before the page's size is known, so the page's
+/// limit can't bound them; they have [`MAX_GLOBALS`] of their own.
+fn jbig2(doc: &Document, s: &Stream) -> Result, String> {
+ let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() {
+ Some(Object::Stream(g)) => {
+ Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?)
+ }
+ _ => None,
+ };
+ let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?;
+ if s.dict.int(b"Width") != Some(i64::from(image.width())) {
+ return Err("the JBIG2 image's width isn't the image's width".into());
+ }
+ if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS {
+ return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height()));
+ }
+ let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0);
+ let mut rows = Rows::new(image.width() as usize, height);
+ image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?;
+ Ok(rows.out)
+}
+
+/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns`
+/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so
+/// the output is bounded by the size the caller gave.
+struct Rows {
+ out: Vec,
+ row: Vec,
+ x: usize,
+ columns: usize,
+ rows: usize,
+ done: usize,
+}
+
+impl Rows {
+ fn new(columns: usize, rows: usize) -> Self {
+ Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 }
+ }
+
+ /// `count` pixels, as 1 samples when `one`.
+ fn push(&mut self, one: bool, count: usize) {
+ let end = self.x.saturating_add(count);
+ if one {
+ for x in self.x..end.min(self.columns) {
+ if let Some(b) = self.row.get_mut(x / 8) {
+ *b |= 0x80 >> (x % 8);
+ }
+ }
+ }
+ self.x = end;
+ }
+
+ fn end_row(&mut self) {
+ if self.done < self.rows {
+ self.out.extend_from_slice(&self.row);
+ self.done += 1;
+ }
+ self.row.fill(0);
+ self.x = 0;
+ }
+}
+
+/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0.
+impl hayro_jbig2::Decoder for Rows {
+ fn push_pixel(&mut self, black: bool) {
+ self.push(!black, 1);
+ }
+ fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
+ self.push(!black, (chunks as usize).saturating_mul(8));
+ }
+ fn next_line(&mut self) {
+ self.end_row();
+ }
+}
+
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
/// alpha, JPEG images included, so cut-out images keep their transparent background.
@@ -108,8 +218,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String>
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
match f.last().map(Vec::as_slice) {
Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()),
- Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()),
- Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()),
+ Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()),
Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()),
_ => {}
}
@@ -148,7 +257,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String>
space = decoded;
(samples, 8)
} else {
- let data = s.decoded().map_err(|e| e.to_string())?;
+ let data = decoded(doc, s)?;
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
1 => 1,
@@ -403,9 +512,11 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result {
};
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
Some(Object::String(s)) => s.bytes.clone(),
- Some(Object::Stream(t)) => {
- t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?
- }
+ // Through its parameters given by reference too, as the image's samples.
+ Some(Object::Stream(t)) => doc
+ .with_direct_parms(t)
+ .decoded_within(MAX_LOOKUP)
+ .map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?,
_ => return Err("an indexed colour space without a lookup table".into()),
};
Ok(Space::Indexed(Box::new(base), hival, table))
@@ -466,7 +577,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option Option> {
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true)));
@@ -476,7 +587,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option>
let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8();
(img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())?
}
- Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None,
+ Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None,
_ => {
let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
@@ -488,7 +599,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option>
_ => return None,
};
let max = (1u32 << bpc.min(8)) - 1;
- unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
+ unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
}
};
if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) {
diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs
index 02bccd5fa8..36b52d4211 100644
--- a/crates/create/src/lib.rs
+++ b/crates/create/src/lib.rs
@@ -2,7 +2,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
-use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
+use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize};
mod extract;
mod upright_image;
@@ -108,9 +108,42 @@ fn jpeg_icc(mut chunks: Vec<(u8, u8, &[u8])>) -> Option> {
complete.then(|| chunks.iter().flat_map(|c| c.2.iter().copied()).collect())
}
+/// The samples of a black-and-white image packed one bit per pixel (1 for white, each row
+/// starting on a byte): eight-bit grey (`n` 1) or RGB (`n` 3) whose every pixel is pure black or
+/// pure white, or one-bit grey, already packed (a bilevel TIFF). `None` for anything else, found
+/// at its first other pixel.
+fn bilevel(data: &[u8], (w, h): (u32, u32), n: usize, bits: Option) -> Option> {
+ let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
+ let stride = w.div_ceil(8);
+ let size = stride.checked_mul(h)?;
+ match (bits, n) {
+ (Some(1), 1) => data.get(..size).map(<[u8]>::to_vec),
+ (Some(8), 1 | 3) if w > 0 => {
+ let samples = data.get(..w.checked_mul(h)?.checked_mul(n)?)?;
+ let mut packed = vec![0u8; size];
+ for (i, px) in samples.chunks_exact(n).enumerate() {
+ let white = match px {
+ [0] | [0, 0, 0] => false,
+ [255] | [255, 255, 255] => true,
+ _ => return None,
+ };
+ if white && let Some(b) = packed.get_mut((i / w) * stride + (i % w) / 8) {
+ *b |= 0x80 >> (i % w % 8);
+ }
+ }
+ Some(packed)
+ }
+ _ => None,
+ }
+}
+
/// Add `img` to `doc` as an image XObject. An ICC profile becomes an `/ICCBased` colour space
/// (with the device space as `/Alternate`); `profiles` shares one object between images that
/// carry the same profile.
+///
+/// A black-and-white image is stored at one bit per pixel, as CCITT Group 4 when that is smaller
+/// than Flate: losslessly, and several times smaller than the eight bits a scan saved as a 1-bit
+/// PNG decodes to. RGB keeps its colour space when it has a colour profile.
fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, ObjRef)>) -> ObjRef {
let mut d = img.dict;
d.set(b"Type".to_vec(), Object::name("XObject"));
@@ -118,12 +151,48 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj
d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64));
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
let device = d.name(b"ColorSpace").map(<[u8]>::to_vec);
- let n = match device.as_deref() {
+ let mut n = match device.as_deref() {
Some(b"DeviceGray") => 1,
Some(b"DeviceRGB") => 3,
Some(b"DeviceCMYK") => 4,
_ => 0,
};
+ let (mut data, mut filtered) = (img.data, img.filtered);
+ let profiled_rgb = n == 3 && img.icc.is_some();
+ if !filtered
+ && !profiled_rgb
+ && let Some(packed) = bilevel(&data, img.px, n, d.int(b"BitsPerComponent"))
+ {
+ if n == 3 {
+ d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
+ n = 1;
+ }
+ d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
+ // The smaller as stored, dictionary included: Group 4's parameters outweigh its saving on
+ // a tiny image.
+ let stored = |d: &Dict, data: &[u8]| {
+ let mut out = Vec::new();
+ serialize(&Object::Dict(d.clone()), &mut out);
+ out.len().saturating_add(data.len())
+ };
+ let mut flate_dict = d.clone();
+ flate_dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
+ let flate = pdfcraft_filters::encode_flate(&packed);
+ (d, data) = match pdfcraft_filters::encode_g4(&packed, img.px.0, img.px.1) {
+ Ok(g4) => {
+ let mut parms = Dict::new();
+ parms.set(b"K".to_vec(), Object::Int(-1));
+ parms.set(b"Columns".to_vec(), Object::Int(i64::from(img.px.0)));
+ parms.set(b"Rows".to_vec(), Object::Int(i64::from(img.px.1)));
+ let mut g4_dict = d.clone();
+ g4_dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
+ g4_dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
+ if stored(&g4_dict, &g4) < stored(&flate_dict, &flate) { (g4_dict, g4) } else { (flate_dict, flate) }
+ }
+ Err(_) => (flate_dict, flate),
+ };
+ filtered = true;
+ }
if let (Some(icc), Some(device)) = (img.icc.and_then(|p| icc_for(p, n)), device) {
let r = match profiles.iter().find(|(p, _)| *p == icc) {
Some((_, r)) => *r,
@@ -142,7 +211,7 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
- let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
+ let stream = if filtered { Stream::from_raw(d, data) } else { Stream::flate(d, &data) };
doc.add(Object::Stream(stream))
}
@@ -562,7 +631,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result, Creat
} else if is_tiff(bytes) {
tiff_pages(name, bytes, wanted)
} else if bytes.starts_with(b"BM") {
- Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
+ Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?])
} else if is_gif(bytes) {
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs
index 3c9aa42a0e..03c93d4a51 100644
--- a/crates/create/src/tests.rs
+++ b/crates/create/src/tests.rs
@@ -186,7 +186,7 @@ fn bmp_gif_and_multi_page_tiff_images() {
let mut enc = image::codecs::gif::GifEncoder::new(&mut gif);
enc.encode(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8).unwrap();
}
- // TIFF: two pages, an 8-bit gray one at 144 dpi and a 1-bit one.
+ // TIFF: two 8-bit gray pages, one at 144 dpi and one of only black and white.
let mut tif = std::io::Cursor::new(Vec::new());
{
let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap();
@@ -206,6 +206,26 @@ fn bmp_gif_and_multi_page_tiff_images() {
assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..))));
}
+/// #4937: a BMP whose header gives the pixel data's offset as 0 is placed as the same image as
+/// the intact file, not shifted by the header bytes.
+#[test]
+fn a_bmp_with_a_zero_pixel_offset_is_placed_like_the_intact_file() {
+ use image::ImageEncoder;
+ let mut bmp = Vec::new();
+ let pixels: Vec = (0..18).map(|i| i * 13).collect();
+ image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&pixels, 3, 2, image::ExtendedColorType::Rgb8).unwrap();
+ let mut zero = bmp.clone();
+ zero[10..14].copy_from_slice(&0u32.to_le_bytes());
+ let samples = |data: Vec| {
+ let doc = reopen(&from_images(&[("a.bmp".into(), data)]).unwrap());
+ let res = doc.resolve(pages(&doc)[0].get(b"Resources").unwrap()).as_dict().cloned().unwrap();
+ let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
+ let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!("not an image") };
+ s.decoded().unwrap()
+ };
+ assert_eq!(samples(zero), samples(bmp));
+}
+
/// #665 lifted the tiff crate's 256 MiB decode limit; a few bytes claiming a 60000x60000 page
/// (3.6 GB of gray pixels) must still be refused, not allocated.
#[test]
@@ -1275,6 +1295,48 @@ fn a_cyclic_indexed_colour_space_is_refused() {
assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped);
}
+/// A baseline TIFF of one uncompressed strip of 1-bit samples, `packed` rows each starting on a
+/// byte, 0 for black (BlackIsZero; TIFF 6.0, section 3): the `tiff` crate writes no 1-bit images.
+fn one_bit_tiff(w: u32, h: u32, packed: &[u8]) -> Vec {
+ // Tag, type (3 SHORT, 4 LONG), value; the strip follows the IFD.
+ let entries: [(u16, u16, u32); 8] =
+ [(256, 4, w), (257, 4, h), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 0), (278, 4, h), (279, 4, packed.len() as u32)];
+ let strip = 8 + 2 + entries.len() as u32 * 12 + 4;
+ let mut out = b"II*\0".to_vec();
+ out.extend(8u32.to_le_bytes());
+ out.extend((entries.len() as u16).to_le_bytes());
+ for (tag, kind, value) in entries {
+ let value = if tag == 273 { strip } else { value };
+ out.extend(tag.to_le_bytes());
+ out.extend(kind.to_le_bytes());
+ out.extend(1u32.to_le_bytes());
+ match kind {
+ 3 => out.extend((value as u16).to_le_bytes().into_iter().chain([0, 0])),
+ _ => out.extend(value.to_le_bytes()),
+ }
+ }
+ out.extend(0u32.to_le_bytes());
+ out.extend_from_slice(packed);
+ out
+}
+
+/// A bilevel TIFF (one bit a sample) stays one bit, stored as Group 4 when that is smaller, and
+/// exports as its picture.
+#[test]
+fn a_one_bit_tiff_is_stored_as_group_4() {
+ let (w, h) = (1200u32, 240u32);
+ let bits = strokes(w, h);
+ let stride = (w as usize).div_ceil(8);
+ let picture: Vec =
+ (0..(w * h) as usize).map(|i| bits[i / w as usize * stride + i % w as usize / 8] & (0x80 >> (i % w as usize % 8)) != 0).collect();
+ let doc = from_images(&[("scan.tif".into(), one_bit_tiff(w, h, &bits))]).unwrap();
+ let im = image_of(&doc, 0);
+ assert_eq!((im.int(b"BitsPerComponent"), im.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), picture);
+}
+
/// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read.
fn gray_tiff(pages: usize, unreadable: bool) -> Vec {
let mut tif = std::io::Cursor::new(Vec::new());
@@ -1318,3 +1380,832 @@ fn a_tiny_gif_on_a_huge_screen_is_refused() {
let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap());
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]);
}
+
+/// A PNG of `w` × `h` samples `data` in `color` at `depth`, with a palette or an ICC profile.
+fn png_of(w: u32, h: u32, color: png::ColorType, depth: png::BitDepth, data: &[u8], palette: Option>, icc: Option>) -> Vec {
+ let mut out = Vec::new();
+ {
+ let mut info = png::Info::with_size(w, h);
+ info.color_type = color;
+ info.bit_depth = depth;
+ info.palette = palette.map(Into::into);
+ info.icc_profile = icc.map(Into::into);
+ let mut writer = png::Encoder::with_info(&mut out, info).unwrap().write_header().unwrap();
+ writer.write_image_data(data).unwrap();
+ }
+ out
+}
+
+/// Rows of `w` one-bit samples (1 white), each on a new byte: lines of glyphs drawn from a few
+/// shapes at uneven spacing, whose edges move a pixel now and then, as a scan of text has them.
+/// (Clean, evenly spaced strokes favour Flate over Group 4.)
+fn strokes(w: u32, h: u32) -> Vec {
+ let stride = (w as usize).div_ceil(8);
+ let mut data = vec![0xFF; stride * h as usize];
+ let mut seed = 0x9E37_79B9_7F4A_7C15u64;
+ let mut next = move || {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed
+ };
+ let (w, h) = (w as usize, h as usize);
+ for line in (4..h.saturating_sub(18)).step_by(24) {
+ let mut x = 8;
+ while x + 16 < w {
+ let wide = 5 + (next() % 4) as usize;
+ let shape = next() % 4;
+ let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| {
+ for y in line + y0..line + y1 {
+ let ragged = |r: u64| match r % 8 {
+ 0 => 0,
+ 1 => 2,
+ _ => 1,
+ };
+ let (left, right) = ((x + x0 + 1).saturating_sub(ragged(next())), x + x1 + ragged(next()));
+ for xx in left..right.saturating_sub(1).min(w) {
+ data[y * stride + xx / 8] &= !(0x80 >> (xx % 8));
+ }
+ }
+ };
+ match shape {
+ 0 => fill(0, 2, 0, 14),
+ 1 => {
+ fill(0, wide, 4, 6);
+ fill(0, wide, 12, 14);
+ fill(0, 2, 4, 14);
+ fill(wide - 2, wide, 4, 14);
+ }
+ 2 => {
+ fill(0, 2, 4, 14);
+ fill(wide - 2, wide, 6, 14);
+ fill(0, wide - 1, 4, 6);
+ }
+ _ => {
+ fill(0, 2, 7, 14);
+ fill(0, 2, 2, 4);
+ }
+ }
+ x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 };
+ }
+ }
+ data
+}
+
+/// The one-bit samples (true for white) a Group 4 image stream decodes to, through an
+/// independent decoder.
+fn g4_rows(s: &Stream) -> Vec> {
+ struct Rows(Vec>, Vec, usize);
+ impl hayro_ccitt::Decoder for Rows {
+ fn push_pixel(&mut self, white: bool) {
+ self.1.push(white);
+ }
+ fn push_pixel_chunk(&mut self, white: bool, chunks: u32) {
+ self.1.extend(std::iter::repeat_n(white, chunks as usize * 8));
+ }
+ fn next_line(&mut self) {
+ let mut row = std::mem::take(&mut self.1);
+ row.truncate(self.2);
+ self.0.push(row);
+ }
+ }
+ let (w, h) = (s.dict.int(b"Width").unwrap() as u32, s.dict.int(b"Height").unwrap() as u32);
+ let settings = hayro_ccitt::DecodeSettings {
+ columns: w,
+ rows: h,
+ end_of_block: true,
+ end_of_line: false,
+ rows_are_byte_aligned: false,
+ encoding: hayro_ccitt::EncodingMode::Group4,
+ invert_black: false,
+ };
+ let mut out = Rows(Vec::new(), Vec::new(), w as usize);
+ hayro_ccitt::decode(&s.raw, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap();
+ out.0
+}
+
+fn image_stream(doc: &Document, page: usize) -> Stream {
+ let p = &pages(doc)[page];
+ let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
+ let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
+ match &*doc.resolve(xo.get(b"Im0").unwrap()) {
+ Object::Stream(s) => s.clone(),
+ _ => panic!("not an image"),
+ }
+}
+
+/// Black-and-white pictures are stored at one bit per pixel, as CCITT Group 4 when that is
+/// smaller, whichever way the file holds them: a 1-bit PNG (which decodes to eight bits), eight-bit
+/// grey of only black and white, a two-colour palette, black-and-white RGB. The bits are the
+/// picture's. A grey profile stays; a picture with any other shade, colour, or an RGB profile is
+/// stored as before.
+#[test]
+fn black_and_white_images_are_stored_at_one_bit() {
+ use png::{BitDepth, ColorType};
+ let (w, h) = (1200u32, 240u32);
+ let bits = strokes(w, h);
+ let stride = (w as usize).div_ceil(8);
+ let white = |x: usize, y: usize| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0;
+ let expected: Vec> = (0..h as usize).map(|y| (0..w as usize).map(|x| white(x, y)).collect()).collect();
+ let grey: Vec = expected.iter().flatten().map(|&wh| if wh { 255 } else { 0 }).collect();
+ let rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect();
+ let pictures = [
+ png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None),
+ png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &grey, None, None),
+ png_of(w, h, ColorType::Indexed, BitDepth::One, &bits.iter().map(|b| !b).collect::>(), Some(vec![255, 255, 255, 0, 0, 0]), None),
+ png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, None),
+ png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, Some(icc_profile(b"GRAY"))),
+ ];
+ let files: Vec<(String, Vec)> = pictures.into_iter().enumerate().map(|(i, p)| (format!("{i}.png"), p)).collect();
+ let doc = reopen(&from_images(&files).unwrap());
+ for page in 0..5 {
+ let s = image_stream(&doc, page);
+ assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])), "page {page}");
+ assert_eq!(g4_rows(&s), expected, "page {page}");
+ assert!(s.raw.len() < bits.len(), "page {page}: {} bytes, smaller than its one-bit samples", s.raw.len());
+ }
+ assert_eq!(image_of(&doc, 3).name(b"ColorSpace"), Some(&b"DeviceGray"[..]), "black-and-white RGB becomes grey");
+ assert_eq!(icc_of(&doc, &image_of(&doc, 4)).map(|(n, ..)| n), Some(1), "the grey profile stays");
+
+ // Another shade, colour, an RGB profile: as before. A tiny picture: one bit, but Flate.
+ let mut shaded = grey.clone();
+ shaded[5] = 128;
+ let mut coloured = rgb.clone();
+ coloured[0] = 200;
+ let others = [
+ png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &shaded, None, None),
+ png_of(w, h, ColorType::Rgb, BitDepth::Eight, &coloured, None, None),
+ png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, Some(icc_profile(b"RGB "))),
+ png_of(2, 2, ColorType::Grayscale, BitDepth::Eight, &[0, 255, 255, 0], None, None),
+ ];
+ let files: Vec<(String, Vec)> = others.into_iter().enumerate().map(|(i, p)| (format!("o{i}.png"), p)).collect();
+ let doc = reopen(&from_images(&files).unwrap());
+ let kept: Vec<(Option, Option>)> =
+ (0..4).map(|p| (image_of(&doc, p).int(b"BitsPerComponent"), image_of(&doc, p).name(b"Filter").map(<[u8]>::to_vec))).collect();
+ assert_eq!(
+ kept[..3],
+ [(Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec()))]
+ );
+ assert_eq!(kept[3], (Some(1), Some(b"FlateDecode".to_vec())), "Group 4 isn't smaller for 2 × 2");
+}
+
+/// The pixels of an exported PNG (RGB), as true for white.
+fn exported_white(png_data: &[u8]) -> Vec {
+ let mut reader = png::Decoder::new(std::io::Cursor::new(png_data.to_vec())).read_info().unwrap();
+ let mut buf = vec![0; reader.output_buffer_size().unwrap()];
+ let frame = reader.next_frame(&mut buf).unwrap();
+ assert_eq!(frame.color_type, png::ColorType::Rgb);
+ buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect()
+}
+
+/// Export ▸ Images reads CCITT fax images: a page created from a 1-bit PNG (Group 4 now) exports as
+/// the picture, as does the picture coded the other way round with /BlackIs1, the picture with its
+/// filter and parameters in arrays, with /K and /Columns given by reference, with a /Rows below its
+/// height, and behind another filter (its parameters by reference too). One whose /Columns aren't
+/// its width is skipped, not misread.
+#[test]
+fn ccitt_fax_images_export_as_their_picture() {
+ use png::{BitDepth, ColorType};
+ let (w, h) = (1200u32, 240u32);
+ let bits = strokes(w, h);
+ let stride = (w as usize).div_ceil(8);
+ let picture: Vec =
+ (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect();
+ let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap();
+ assert_eq!(image_of(&doc, 0).name(b"Filter"), Some(&b"CCITTFaxDecode"[..]));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), picture);
+
+ // The same picture coded with 1 for black, which /BlackIs1 says.
+ let r = {
+ let page = pdfcraft_model::pages(&doc)[0].clone();
+ let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
+ let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
+ xo.get(b"Im0").and_then(Object::as_ref).unwrap()
+ };
+ let base = image_of(&doc, 0);
+ let with = |black_is_1: bool, columns: i64, data: Vec| {
+ let mut d = base.clone();
+ let mut parms = Dict::new();
+ parms.set(b"K".to_vec(), Object::Int(-1));
+ parms.set(b"Columns".to_vec(), Object::Int(columns));
+ parms.set(b"Rows".to_vec(), Object::Int(i64::from(h)));
+ parms.set(b"BlackIs1".to_vec(), Object::Bool(black_is_1));
+ d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
+ d.remove(b"Length");
+ Stream::from_raw(d, data)
+ };
+ let inverted: Vec = bits.iter().map(|b| !b).collect();
+ let stream = with(true, i64::from(w), pdfcraft_filters::encode_g4(&inverted, w, h).unwrap());
+ doc.set(r, Object::Stream(stream));
+ let out = extract_images(&doc, &[0], 0);
+ assert_eq!(exported_white(&out.images[0].data), picture, "BlackIs1");
+
+ let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap());
+ let parms = stream.dict.get(b"DecodeParms").cloned().unwrap();
+ stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![parms]));
+ stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")]));
+ doc.set(r, Object::Stream(stream));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), picture, "in arrays");
+
+ let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap());
+ let mut parms = stream.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap();
+ parms.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1))));
+ parms.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(i64::from(w)))));
+ parms.set(b"Rows".to_vec(), Object::Int(1));
+ stream.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
+ doc.set(r, Object::Stream(stream));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), picture, "/K and /Columns by reference, /Rows 1");
+
+ // Behind another filter, with its parameters second in the array, as 7.4.1 pairs them.
+ let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap();
+ let hex = g4.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes();
+ for (outer, data) in [("ASCIIHexDecode", hex), ("FlateDecode", pdfcraft_filters::encode_flate(&g4))] {
+ let mut stream = with(false, i64::from(w), data);
+ let parms = stream.dict.get(b"DecodeParms").cloned().unwrap();
+ stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms]));
+ stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name(outer), Object::name("CCITTFaxDecode")]));
+ doc.set(r, Object::Stream(stream));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{outer}: {:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), picture, "behind {outer}");
+ }
+ // Behind Flate with a PNG predictor, whose parameters are given by reference.
+ let padded: Vec = g4.iter().copied().chain(std::iter::repeat_n(0, 16 - g4.len() % 16)).collect();
+ let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 16, early_change: 1 };
+ let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &padded).unwrap();
+ let mut flate_parms = Dict::new();
+ flate_parms.set(b"Predictor".to_vec(), Object::Int(12));
+ flate_parms.set(b"Columns".to_vec(), Object::Int(16));
+ let flate_parms = doc.add(Object::Dict(flate_parms));
+ let mut stream = with(false, i64::from(w), predicted);
+ let parms = stream.dict.get(b"DecodeParms").cloned().unwrap();
+ stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Ref(flate_parms), parms]));
+ stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")]));
+ doc.set(r, Object::Stream(stream));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), picture, "behind Flate with a predictor given by reference");
+ // CCITT before another filter isn't a picture yet.
+ let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_flate(&g4));
+ stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode"), Object::name("FlateDecode")]));
+ doc.set(r, Object::Stream(stream));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.images.is_empty() && out.skipped.len() == 1, "CCITT, then Flate: skipped");
+
+ let stream = with(false, i64::from(w) + 8, pdfcraft_filters::encode_g4(&bits, w, h).unwrap());
+ doc.set(r, Object::Stream(stream));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.images.is_empty() && out.skipped.len() == 1, "columns other than the width: skipped");
+}
+
+/// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's
+/// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with
+/// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `/K` above 0 has it). Fill bits
+/// (0s) go where `fill` says.
+fn group_3(rows: &[impl AsRef<[u32]>], eol: bool, tag: bool, fill: Fill) -> Vec {
+ // The codes for runs 0 to 63, in order.
+ const WHITE: &str = concat!(
+ "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ",
+ "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ",
+ "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ",
+ "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ",
+ "01011011 01001010 01001011 00110010 00110011 00110100",
+ );
+ const BLACK: &str = concat!(
+ "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ",
+ "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ",
+ "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ",
+ "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ",
+ "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ",
+ "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ",
+ "000001100110 000001100111",
+ );
+ let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64");
+ let mut bits = String::new();
+ for row in rows {
+ // The end-of-line code is 12 bits.
+ let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 };
+ while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) {
+ bits.push('0');
+ }
+ if eol {
+ bits.push_str("000000000001");
+ }
+ if tag {
+ bits.push('1');
+ }
+ row.as_ref().iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run)));
+ }
+ bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect()
+}
+
+/// `count` text-like pictures `w` by `h` for [`group_3`], from a fixed seed: rows of random runs,
+/// long white ones and short black ones, and a row may start black.
+fn random_pictures(w: u32, h: u32, count: usize) -> Vec>> {
+ let mut seed = 0x9E37_79B9_7F4A_7C15u64;
+ let mut random = |n: u64| {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ (seed % n) as u32
+ };
+ let mut row = || {
+ let (mut runs, mut left) = (Vec::new(), w);
+ while left > 0 {
+ let run = match runs.len() % 2 {
+ 0 if runs.is_empty() => random(31),
+ 0 => 1 + random(30),
+ _ => 1 + random(5),
+ };
+ runs.push(run.min(left));
+ left -= run.min(left);
+ }
+ runs
+ };
+ (0..count).map(|_| (0..h).map(|_| row()).collect()).collect()
+}
+
+/// The picture `rows` of runs (white first) make, `w` by `h`, true where white.
+fn white_of(rows: &[Vec], w: u32, h: u32) -> Vec {
+ let mut white = vec![false; (w * h) as usize];
+ for (y, row) in rows.iter().enumerate() {
+ let mut x = y * w as usize;
+ for (i, run) in row.iter().enumerate() {
+ white[x..x + *run as usize].fill(i % 2 == 0);
+ x += *run as usize;
+ }
+ }
+ white
+}
+
+/// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a
+/// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it).
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Fill {
+ None,
+ RowStart,
+ CodeEnd,
+}
+
+/// What Export ▸ Images claims for CCITT and for parameters given by reference, each checked
+/// against the same picture stored another way: Group 3 one-dimensional data (`/K 0`) exports as
+/// its picture; a CCITT soft mask and a CCITT stencil `/Mask` export as the same masks in Flate;
+/// and a Flate image's PNG predictor, and an indexed image's lookup table stream with one, read
+/// the same with their `/DecodeParms` given by reference as direct.
+#[test]
+fn group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture() {
+ use png::{BitDepth, ColorType};
+ let (w, h) = (64u32, 16u32);
+ let rows: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &[32u32, 32][..] } else { &[16, 16, 32][..] }).collect();
+ let mut bits = vec![0u8; 8 * h as usize];
+ for (y, runs) in rows.iter().enumerate() {
+ let mut x = 0;
+ for (i, run) in runs.iter().enumerate() {
+ if i % 2 == 0 {
+ (x..x + *run as usize).for_each(|xx| bits[y * 8 + xx / 8] |= 0x80 >> (xx % 8));
+ }
+ x += *run as usize;
+ }
+ }
+ let picture: Vec = (0..(w * h) as usize).map(|i| bits[i / 8] & (0x80 >> (i % 8)) != 0).collect();
+ let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap();
+ let r = {
+ let page = pdfcraft_model::pages(&doc)[0].clone();
+ let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
+ let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
+ xo.get(b"Im0").and_then(Object::as_ref).unwrap()
+ };
+ let image = |entries: &[(&str, Object)]| {
+ 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.into()));
+ d.set(b"Height".to_vec(), Object::Int(h.into()));
+ for (k, v) in entries {
+ d.set(k.as_bytes().to_vec(), v.clone());
+ }
+ d
+ };
+ let fax = |k: i64| {
+ let mut parms = Dict::new();
+ parms.set(b"K".to_vec(), Object::Int(k));
+ parms.set(b"Columns".to_vec(), Object::Int(w.into()));
+ parms.set(b"Rows".to_vec(), Object::Int(h.into()));
+ [("Filter", Object::name("CCITTFaxDecode")), ("DecodeParms", Object::Dict(parms))]
+ };
+ let grey = |bits: i64| [("BitsPerComponent", Object::Int(bits)), ("ColorSpace", Object::name("DeviceGray"))];
+
+ // One-dimensional (/K 0) plain, with end-of-line codes, with each row on a byte, and with
+ // both; mixed (/K 1, each row tagged one-dimensional) with end-of-line codes and without.
+ // Byte-aligned with end-of-line codes either way: each code starting on a byte, or ending on
+ // one (T.4's fill, as TIFF writes it). /EncodedByteAlign is given by reference once, and so is
+ // a /BlackIs1 that inverts the picture.
+ let inverted: Vec = picture.iter().map(|white| !white).collect();
+ for (k, eol, fill, align_by_reference, black_is_1) in [
+ (0, false, Fill::None, false, false),
+ (0, true, Fill::None, false, false),
+ (0, false, Fill::RowStart, true, false),
+ (0, true, Fill::RowStart, false, false),
+ (0, true, Fill::CodeEnd, false, false),
+ (1, true, Fill::None, false, false),
+ (1, false, Fill::None, false, false),
+ (1, true, Fill::CodeEnd, false, false),
+ (0, false, Fill::None, false, true),
+ ] {
+ let mut d = image(&[&grey(1)[..], &fax(k)].concat());
+ let align = Object::Bool(fill != Fill::None);
+ let align = if align_by_reference { Object::Ref(doc.add(align)) } else { align };
+ let inverts = Object::Ref(doc.add(Object::Bool(black_is_1)));
+ if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") {
+ parms.set(b"EndOfLine".to_vec(), Object::Bool(eol));
+ parms.set(b"EncodedByteAlign".to_vec(), align);
+ parms.set(b"BlackIs1".to_vec(), inverts);
+ }
+ doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows, eol, k > 0, fill))));
+ let case = format!("/K {k}, EndOfLine {eol}, fill {fill:?}, BlackIs1 {black_is_1}");
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), if black_is_1 { inverted.clone() } else { picture.clone() }, "{case}");
+ }
+ // Byte-aligned rows against /EndOfLine, over several pictures, as how a misreading comes out
+ // depends on the picture: no codes (as TIFF's Modified Huffman has it), and /EndOfLine without
+ // them; codes, each starting on a byte or ending on one, /EndOfLine or not. Each exports as its
+ // picture, and a byte short is reported. The first picture is stripes, every row 32 white then
+ // 32 black; the others are text-like rows of random runs.
+ let image_with = |doc: &mut Document, k: i64, eol: bool, data: Vec| {
+ let mut d = image(&[&grey(1)[..], &fax(k)].concat());
+ if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") {
+ parms.set(b"EndOfLine".to_vec(), Object::Bool(eol));
+ parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(true));
+ }
+ doc.set(r, Object::Stream(Stream::from_raw(d, data)));
+ extract_images(doc, &[0], 0)
+ };
+ let mut pictures = vec![vec![vec![32u32, 32]; h as usize]];
+ pictures.extend(random_pictures(w, h, 8));
+ for (n, runs) in pictures.iter().enumerate() {
+ let white = white_of(runs, w, h);
+ for k in [0, 1] {
+ for (codes, fill, eol) in [
+ (false, Fill::RowStart, false),
+ (false, Fill::RowStart, true),
+ (true, Fill::RowStart, false),
+ (true, Fill::RowStart, true),
+ (true, Fill::CodeEnd, false),
+ (true, Fill::CodeEnd, true),
+ ] {
+ let out = image_with(&mut doc, k, eol, group_3(runs, codes, k > 0, fill));
+ let case = format!("picture {n}, /K {k}, codes {codes}, fill {fill:?}, /EndOfLine {eol}");
+ assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped);
+ assert_eq!(exported_white(&out.images[0].data), white, "{case}");
+ // A byte short, it is reported, not read the other way into other rows.
+ let mut short = group_3(runs, codes, k > 0, fill);
+ short.pop();
+ let out = image_with(&mut doc, k, eol, short);
+ assert!(out.images.is_empty() && out.skipped.len() == 1, "{case}, a byte short: {:?}", out.skipped);
+ // At /K 1, codes with none before the first row (the first code and its fill are 2
+ // bytes here) are read on bytes: the picture or reported, never another picture.
+ if k == 1 && fill == Fill::CodeEnd {
+ let out = image_with(&mut doc, k, eol, group_3(runs, codes, true, fill).split_off(2));
+ assert!(out.images.iter().all(|i| exported_white(&i.data) == white), "{case}, no code first");
+ }
+ }
+ }
+ }
+ // Group 4 rows on bytes keep their aligned read, /EndOfLine or not: here white rows, each the
+ // one code 1 (vertical, no change) and its fill.
+ for eol in [false, true] {
+ let out = image_with(&mut doc, -1, eol, vec![0x80; h as usize]);
+ assert!(out.skipped.is_empty(), "byte-aligned Group 4, /EndOfLine {eol}: {:?}", out.skipped);
+ assert!(exported_white(&out.images[0].data).iter().all(|white| *white), "byte-aligned Group 4, /EndOfLine {eol}");
+ }
+
+ // A grey picture under the same bits as a soft mask, then as a stencil mask: CCITT against Flate.
+ let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap();
+ for (key, mask) in [("SMask", grey(1).to_vec()), ("Mask", vec![("ImageMask", Object::Bool(true))])] {
+ let mut exported = Vec::new();
+ for coded in [true, false] {
+ let m = if coded { Stream::from_raw(image(&[&mask[..], &fax(-1)].concat()), g4.clone()) } else { Stream::flate(image(&mask), &bits) };
+ let m = doc.add(Object::Stream(m));
+ let d = image(&[&grey(8)[..], &[(key, Object::Ref(m))]].concat());
+ doc.set(r, Object::Stream(Stream::flate(d, &vec![100u8; (w * h) as usize])));
+ exported.push(exported_rgba(&doc));
+ }
+ assert!(exported[0].1.chunks(4).any(|p| p[3] == 0), "{key}: masked somewhere");
+ assert_eq!(exported[0], exported[1], "{key}: CCITT as Flate");
+ }
+
+ // A PNG predictor given by reference, for the samples and for a lookup table.
+ let predicted = |rows: &[u8], columns: i64| {
+ let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns, early_change: 1 };
+ let mut parms = Dict::new();
+ parms.set(b"Predictor".to_vec(), Object::Int(12));
+ parms.set(b"Columns".to_vec(), Object::Int(columns));
+ (pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, rows).unwrap(), parms)
+ };
+ let shades: Vec = (0..w * h).map(|i| (i * 7 % 256) as u8).collect();
+ let (table, table_parms) = predicted(&[255, 0, 0, 0, 0, 255], 3);
+ let indices: Vec = (0..w * h).map(|i| (i % 3 % 2) as u8).collect();
+ let mut exported = Vec::new();
+ for by_reference in [false, true] {
+ let parms = |doc: &mut Document, p: &Dict| if by_reference { Object::Ref(doc.add(Object::Dict(p.clone()))) } else { Object::Dict(p.clone()) };
+ let (samples, sample_parms) = predicted(&shades, w.into());
+ let sample_parms = parms(&mut doc, &sample_parms);
+ let d = image(&[&grey(8)[..], &[("Filter", Object::name("FlateDecode")), ("DecodeParms", sample_parms)]].concat());
+ doc.set(r, Object::Stream(Stream::from_raw(d, samples)));
+ let plain = exported_rgba(&doc);
+
+ let mut lookup = Dict::new();
+ lookup.set(b"Filter".to_vec(), Object::name("FlateDecode"));
+ lookup.set(b"DecodeParms".to_vec(), parms(&mut doc, &table_parms));
+ let lookup = doc.add(Object::Stream(Stream::from_raw(lookup, table.clone())));
+ let cs = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), Object::Ref(lookup)]);
+ let d = image(&[("BitsPerComponent", Object::Int(8)), ("ColorSpace", cs)]);
+ doc.set(r, Object::Stream(Stream::flate(d, &indices)));
+ exported.push((plain, exported_rgba(&doc)));
+ }
+ assert_eq!(exported[0].0.1.chunks(4).map(|p| p[0]).collect::>(), shades, "the samples, direct");
+ assert_eq!(exported[0].1.1.get(..8), Some(&[255, 0, 0, 255, 0, 0, 255, 255][..]), "red, then blue");
+ assert_eq!(exported[1], exported[0], "by reference as direct");
+}
+
+/// An image whose PNG predictor is given by reference exports as its picture: its `/DecodeParms`
+/// dictionary given by reference, as an array's entry, or direct with its numbers by reference. So
+/// do an indexed image whose lookup table is a stream with its `/DecodeParms` given by reference,
+/// and a JBIG2 image whose `/JBIG2Globals` stream has them so. `Stream::decoded` reads only a
+/// direct `/DecodeParms` with direct numbers: the row tags stayed in the samples, and the picture
+/// came out shifted.
+#[test]
+fn images_whose_predictor_is_given_by_reference_export_as_their_picture() {
+ // PNG predictor parameters for `columns` one-byte samples a row.
+ let parms = |columns: i64| {
+ let mut p = Dict::new();
+ p.set(b"Predictor".to_vec(), Object::Int(15));
+ p.set(b"Colors".to_vec(), Object::Int(1));
+ p.set(b"BitsPerComponent".to_vec(), Object::Int(8));
+ p.set(b"Columns".to_vec(), Object::Int(columns));
+ p
+ };
+ // Two rows of four grey samples, each behind a PNG "None" row tag.
+ let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]);
+ let grey: Vec = [10u8, 20, 30, 40, 50, 60, 70, 80].iter().flat_map(|v| [*v, *v, *v, 255]).collect();
+ for form in ["a reference", "an array of a reference", "numbers by reference"] {
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |doc| {
+ let mut d = image_dict(4, 2, "DeviceGray", 8);
+ d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
+ let decode_parms = match form {
+ "numbers by reference" => {
+ let mut p = parms(4);
+ for key in [&b"Predictor"[..], b"Columns"] {
+ let v = p.get(key).cloned().unwrap();
+ p.set(key.to_vec(), Object::Ref(doc.add(v)));
+ }
+ Object::Dict(p)
+ }
+ "a reference" => Object::Ref(doc.add(Object::Dict(parms(4)))),
+ _ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms(4))))]),
+ };
+ d.set(b"DecodeParms".to_vec(), decode_parms);
+ Stream::from_raw(d, predicted.clone())
+ });
+ assert_eq!(exported_rgba(&doc), ("png", grey.clone()), "/DecodeParms as {form}");
+ }
+ // An indexed image's lookup table, red then blue, as a stream behind a "None" row tag.
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |doc| {
+ let mut t = Dict::new();
+ t.set(b"Filter".to_vec(), Object::name("FlateDecode"));
+ t.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(6)))));
+ let table = doc.add(Object::Stream(Stream::from_raw(t, pdfcraft_filters::encode_flate(&[0, 255, 0, 0, 0, 0, 255]))));
+ let mut d = image_dict(4, 1, "DeviceGray", 8);
+ d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::Ref(table)]));
+ Stream::from_raw(d, vec![0, 1, 0, 1])
+ });
+ let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]);
+ assert_eq!(exported_rgba(&doc), ("png", [red, blue, red, blue].concat()), "the lookup table's /DecodeParms by reference");
+ // A JBIG2 image whose globals hold one extension segment (ISO/IEC 14492 7.4.14) a decoder
+ // ignores, behind a "None" row tag. With the tag left in, the globals can't be read and the
+ // image is skipped.
+ let segment = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
+ let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |doc| {
+ let mut g = Dict::new();
+ g.set(b"Filter".to_vec(), Object::name("FlateDecode"));
+ g.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(segment.len() as i64)))));
+ let tagged: Vec = [0].iter().chain(&segment).copied().collect();
+ let globals = doc.add(Object::Stream(Stream::from_raw(g, pdfcraft_filters::encode_flate(&tagged))));
+ let mut decode_parms = Dict::new();
+ decode_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
+ let mut d = image_dict(16, 2, "DeviceGray", 1);
+ d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
+ d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms));
+ Stream::from_raw(d, pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap())
+ });
+ let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect();
+ assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference");
+}
+
+/// An image the optimizer stored as JBIG2 symbols (a symbol dictionary and text regions, rather
+/// than one generic region) exports as its picture.
+#[test]
+fn symbol_coded_jbig2_images_export_as_their_picture() {
+ const LETTERS: [[&str; 7]; 3] = [
+ ["#####", "#....", "####.", "#....", "#....", "#....", "#####"],
+ [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
+ ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"],
+ ];
+ let (w, h) = (400usize, 40usize);
+ let stride = w.div_ceil(8);
+ // 1 samples white, as a grey image's are; the letters' pixels 0.
+ let mut bits = vec![0xFFu8; stride * h];
+ for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() {
+ for (k, x) in (3..w - 6).step_by(7).enumerate() {
+ for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() {
+ for (dx, c) in row.bytes().enumerate() {
+ if c == b'#' {
+ bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
+ }
+ }
+ }
+ }
+ }
+ let jbig2 = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap();
+ let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
+ assert!(jbig2.len() < generic.len(), "symbol coded: {} bytes, generic {}", jbig2.len(), generic.len());
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |_| {
+ let mut d = image_dict(w as i64, h as i64, "DeviceGray", 1);
+ d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
+ Stream::from_raw(d, jbig2)
+ });
+ let picture: Vec = (0..w * h)
+ .flat_map(|i| if bits[i / w * stride + i % w / 8] & (0x80 >> (i % w % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] })
+ .collect();
+ assert_eq!(exported_rgba(&doc), ("png", picture));
+}
+
+/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as
+/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in
+/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the
+/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image,
+/// or larger than an exported image may be (however small the image's `/Height`), or data that
+/// isn't JBIG2, skips the image.
+#[test]
+fn jbig2_images_export_as_their_picture() {
+ let (w, h) = (203i64, 41i64);
+ let stride = 26;
+ let mut seed = 0x2545_F491_4F6C_DD1Du64;
+ let bits: Vec = (0..stride * h as usize)
+ .map(|_| {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed as u8
+ })
+ .collect();
+ // The first `rows` rows as RGBA: a 1 sample white, a 0 black.
+ let picture = |rows: i64| -> Vec {
+ (0..rows as usize)
+ .flat_map(|y| (0..w as usize).map(move |x| (x, y)))
+ .flat_map(|(x, y)| {
+ let v = if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 };
+ [v, v, v, 255]
+ })
+ .collect()
+ };
+ let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
+ // A page whose image is `data` as JBIG2, `width` × `height`. With `globals`, its /DecodeParms
+ // (an array when `.1`) name a globals stream; with `mask`, it is the stencil /Mask of a grey
+ // image.
+ let page = |width: i64, height: i64, data: &[u8], globals: Option<(&[u8], bool)>, mask: bool| {
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |doc| {
+ let mut d = image_dict(width, height, "DeviceGray", 1);
+ d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
+ if let Some((g, in_array)) = globals {
+ let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), g.to_vec())));
+ let mut parms = Dict::new();
+ parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
+ d.set(b"DecodeParms".to_vec(), if in_array { Object::Array(vec![Object::Dict(parms)]) } else { Object::Dict(parms) });
+ }
+ if !mask {
+ return Stream::from_raw(d, data.to_vec());
+ }
+ d.remove(b"ColorSpace");
+ d.set(b"ImageMask".to_vec(), Object::Bool(true));
+ let m = doc.add(Object::Stream(Stream::from_raw(d, data.to_vec())));
+ let mut image = image_dict(width, height, "DeviceGray", 8);
+ image.set(b"Mask".to_vec(), Object::Ref(m));
+ Stream::from_raw(image, vec![128; (width * height) as usize])
+ });
+ doc
+ };
+ let skipped = |doc: &Document| {
+ let out = extract_images(doc, &[0], 0);
+ out.images.is_empty() && out.skipped.len() == 1
+ };
+
+ assert_eq!(exported_rgba(&page(w, h, &jbig2, None, false)), ("png", picture(h)));
+ assert_eq!(exported_rgba(&page(w, h, &jbig2, Some((&[][..], false)), false)).1, picture(h), "empty globals");
+ for in_array in [false, true] {
+ assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}");
+ }
+ assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height");
+ for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] {
+ assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len());
+ }
+ // The same stream with its page information (the first segment's data, after its 11-byte
+ // header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the
+ // export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page
+ // (it takes pages of up to about 2^28 pixels), so it is refused first.
+ let mut large = jbig2.clone();
+ large[11..15].copy_from_slice(&20_000u32.to_be_bytes());
+ large[15..19].copy_from_slice(&10_000u32.to_be_bytes());
+ assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size");
+ // Refused the same ways as a stencil /Mask, it is left out: the image exports opaque.
+ for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] {
+ let rgba = exported_rgba(&page(width, height, mask, None, true)).1;
+ assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask");
+ assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out");
+ }
+ // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused.
+ for (rows, refused) in [(5_000u32, false), (5_001, true)] {
+ large[15..19].copy_from_slice(&rows.to_be_bytes());
+ let doc = page(20_000, 4, &large, None, false);
+ assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}");
+ if !refused {
+ assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4);
+ }
+ }
+ // Damaged data never panics.
+ for end in [0, 1, 10, 20, 30, jbig2.len() / 2] {
+ let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0);
+ }
+
+ // As a stencil /Mask, its 1 samples (JBIG2 white) hide the image.
+ let alpha: Vec = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect();
+ assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha);
+}
+
+/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have
+/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to
+/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes
+/// in the file. Globals of exactly the limit are read, and the limit is the same through an
+/// indirect `/DecodeParms`, in an array or not.
+#[test]
+fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
+ const LIMIT: usize = 32 << 20;
+ let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
+ let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
+ let picture: Vec