Merge upstream/main into fix/type3-cleanup-keeps-other-drawers

This commit is contained in:
oracola91
2026-10-29 23:33:13 +02:00
383 changed files with 30866 additions and 7917 deletions
+18 -18
View File
@@ -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"
+2
View File
@@ -22,6 +22,7 @@ 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.
- **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 +40,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 <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
- **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 "<terms>"` 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 <commit> 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 `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
Generated
+46 -47
View File
@@ -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,7 +1279,7 @@ 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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"chrono",
"dunce",
@@ -4205,7 +4205,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"chrono",
"craft-update",
@@ -4229,7 +4229,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4241,7 +4241,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4263,7 +4263,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"base64 0.23.1",
"flate2",
@@ -4285,7 +4285,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"base64 0.23.1",
"pdfcraft-audit",
@@ -4303,11 +4303,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 +4315,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4330,7 +4330,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4338,6 +4338,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-xfa",
"png 0.18.1",
"thiserror 2.0.21",
"tiff",
@@ -4346,7 +4347,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"aes",
"md-5",
@@ -4358,7 +4359,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4373,7 +4374,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4414,14 +4415,14 @@ 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 = [
"flate2",
"proptest",
@@ -4431,7 +4432,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4441,7 +4442,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4454,11 +4455,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 +4467,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4479,14 +4480,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 +4498,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4512,11 +4513,11 @@ dependencies = [
[[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 +4528,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4537,7 +4538,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4548,7 +4549,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4560,7 +4561,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4576,7 +4577,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4595,7 +4596,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4631,11 +4632,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 +4688,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"eframe",
"egui",
@@ -4699,7 +4700,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"base64 0.23.1",
"image",
@@ -4713,7 +4714,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -7350,8 +7351,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",
+45 -41
View File
@@ -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,51 @@ 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 = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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"
@@ -91,6 +91,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 +128,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
+5 -5
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" width="128" height="128" alt="PdfCraft icon: an engraved lion on a teal tile">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" width="128" height="128" alt="PdfCraft icon: an engraved lion on a red tile">
</p>
<h1 align="center">PdfCraft</h1>
@@ -21,10 +21,10 @@
</p>
<p align="center">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563?style=flat-square">
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a?style=flat-square">
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-b70228?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-8a011e?style=flat-square">
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-b70228?style=flat-square">
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-8a011e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_pdfcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/pdfcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
+1 -1
View File
@@ -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)
+3 -1
View File
@@ -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 --------------------------------------------------------------------------------
+1 -1
View File
@@ -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 -----------------------------------------------------------------
+36
View File
@@ -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<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /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:?}");
}
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
# PdfCraft app icon
<img src="pdfcraft-small.svg" alt="PdfCraft app icon: an engraved lion's head on green" width="128">
<img src="pdfcraft-small.svg" alt="PdfCraft app icon: an engraved lion's head on red" width="128">
**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/<n>x<n>/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) |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 B

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+3 -3
View File
@@ -4,10 +4,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<g transform="translate(-2 -2) scale(2.8)" stroke="#0b0b0c" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path fill="#efe9dc" d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path fill="#c8ece4" d="M14 2v5a1 1 0 0 0 1 1h5" />
<path fill="none" stroke="#12a58a" d="M8 12h8M8 15h5" />
<path fill="#f3cfd6" d="M14 2v5a1 1 0 0 0 1 1h5" />
<path fill="none" stroke="#b70228" d="M8 12h8M8 15h5" />
</g>
<rect x="3" y="34" width="56" height="23" rx="4" fill="#087f6b" />
<rect x="3" y="34" width="56" height="23" rx="4" fill="#8a011e" />
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 50V41h5a3 3 0 0 1 0 6h-5" />
<path d="M27 41v9h3a4.5 4.5 0 0 0 0-9z" />

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 1023 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 451 KiB

+1
View File
@@ -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)" },
+6 -3
View File
@@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
// Where each id's last glyph ended, in user space.
let mut ends: HashMap<i64, (f64, f64)> = HashMap::new();
let mut gs = TextState::default();
let mut saved: Vec<TextState> = 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<i64, String> {
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" => {
+13
View File
@@ -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<String> = 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();
+1 -1
View File
@@ -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
```
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+52 -38
View File
@@ -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<usize, AnnotError> {
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<Object>) -> 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.
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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 ─────────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -41,7 +41,7 @@ pub use modify::{
};
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;
+1 -1
View File
@@ -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 ───────────────────────────────────────────────────────────────────────
+7 -25
View File
@@ -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.
@@ -499,17 +500,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<Rgb>,
opacity: Option<f64>,
width: Option<f64>,
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 +513,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 +683,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
/// 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<usize, AnnotError> {
pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result<usize, AnnotError> {
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,
+45 -14
View File
@@ -303,7 +303,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 +313,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 +377,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 +559,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 +847,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 +882,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 +1208,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 +1276,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 +1398,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 +1426,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 +1507,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<f64> = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
@@ -1522,7 +1553,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 +1822,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"[..]));
+28 -1
View File
@@ -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<Rgb>,
}
/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StyleChange {
pub color: Option<Rgb>,
pub opacity: Option<f64>,
pub width: Option<f64>,
pub endings: Option<Vec<LineEnding>>,
}
impl StyleChange {
pub fn new(color: Option<Rgb>, opacity: Option<f64>, width: Option<f64>, endings: Option<Vec<LineEnding>>) -> 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 }
+1 -1
View File
@@ -1,4 +1,4 @@
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<Value> {
+11 -4
View File
@@ -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<Value> {
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<Value> = 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<Value> = 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<Value> {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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<Value> {
+32 -4
View File
@@ -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, json, tools};
/// One piece of a tool's result.
#[derive(Clone, Debug, PartialEq)]
@@ -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<Value> = 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")?;
@@ -742,7 +770,7 @@ impl Automation {
let ids: Vec<Value> = 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<Value> = Vec::new();
@@ -755,7 +783,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
+1 -1
View File
@@ -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;
+37 -8
View File
@@ -600,17 +600,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)
}
@@ -671,12 +677,35 @@ impl Automation {
Ok(json!(seq.iter().map(|x| x.1).collect::<Vec<_>>()))
}
/// Each page's tab order (Page properties ▸ Tab order), in page order.
fn page_tabs(&self, a: &Args) -> Result<Value> {
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<Value> {
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<Value> {
+31 -1
View File
@@ -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<Vec<pdfcraft_engine::text_style_bookmarks::TextStyle>> {
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<Vec<pdfcraft_engine::RangeStyle>> {
let Some(value) = value else { return Ok(Vec::new()) };
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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 -------------------------------------------------------------------------------
+38 -5
View File
@@ -347,6 +347,39 @@ pub fn tools() -> Vec<ToolDef> {
"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(),
@@ -464,7 +497,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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"],
@@ -478,11 +511,11 @@ pub fn tools() -> Vec<ToolDef> {
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",
@@ -1135,7 +1168,7 @@ pub fn tools() -> Vec<ToolDef> {
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." } }),
+352 -70
View File
@@ -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<u8> {
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<String> = 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<Value> = (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<u8> {
let objs: Vec<String> = 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<f64> = 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);
}
@@ -2548,6 +2640,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 +2924,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("<p>Needs manual check: 1</p>"));
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"),
@@ -4004,6 +4125,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::<Vec<_>>(), ["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 +5058,23 @@ 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");
}
#[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 +5421,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 +5450,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 +5465,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 +6074,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 +6090,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 +6112,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 +6124,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.
@@ -6449,6 +6648,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 +6683,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();
@@ -7406,6 +7614,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 +7644,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 +7657,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 +7667,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 +8091,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);
@@ -9175,3 +9385,75 @@ 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<Vec<i64>> {
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"]);
}
+2
View File
@@ -8,3 +8,5 @@ mod button_caption;
mod form_bulk;
mod hostile_args;
mod optimize_content_boundaries;
mod redact_after_stray_q_form;
mod redact_text_after_q;
@@ -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<u8> {
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);
}
@@ -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<u8> {
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);
}
}
+1 -1
View File
@@ -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, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared};
impl Document {
pub fn bytes(&self) -> &Arc<Vec<u8>> {
+1 -1
View File
@@ -1,7 +1,7 @@
use super::reader::{find, generated_id};
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
+1 -1
View File
@@ -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)]
+1 -1
View File
@@ -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`).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, BTreeMap, Lexer};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XrefEntry {
+2
View File
@@ -12,6 +12,8 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-fonts = { workspace = true }
# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
pdfcraft-xfa = { workspace = true }
png = "0.18"
tiff = "0.11"
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
+1 -1
View File
@@ -562,7 +562,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, 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]) {
+20
View File
@@ -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<u8> = (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<u8>| {
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]
+4 -1
View File
@@ -2,7 +2,10 @@ use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
};
use super::types::{AddedText, Align, Family};
use super::*;
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
literal, matrix_number, n, shaped_glyph, win_ansi,
};
fn font_name(base: &str) -> String {
format!("PCF{}", base.replace('-', ""))
+4 -1
View File
@@ -1,5 +1,8 @@
use super::types::{AddedImage, AddedText};
use super::*;
use super::{
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew,
shape_thai,
};
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
+1 -1
View File
@@ -1,7 +1,7 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::*;
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged};
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
let r = nums(doc, d.get(b"Rect"));
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::norm;
use super::*;
use super::{Content, MarkKind, ObjRef, Std14};
pub(super) const TAG: &str = "Added";
+1 -1
View File
@@ -5,7 +5,7 @@ use super::place::{
END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place,
place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role,
};
use super::*;
use super::{Document, EditError, Object, added, helvetica_width};
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
/// those pages are removed first (Acrobat's Replace Existing).
+25
View File
@@ -16,6 +16,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::{EditError, check, n, page_list, place_tagged};
const HIDDEN: i64 = 2;
const NO_ROTATE: i64 = 16;
const NO_VIEW: i64 = 32;
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
@@ -74,6 +75,28 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> {
Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy])
}
/// `m` followed by a turn of `degrees` (the page's /Rotate) about the upper-left corner of
/// `rect`: a NoRotate annotation stays upright on a rotated page (ISO 32000-2 §12.5.3), its
/// upper-left corner where it was, which is how MuPDF and Poppler draw it.
fn upright(m: [f64; 6], degrees: i64, rect: [f64; 4]) -> [f64; 6] {
let (sin, cos) = match degrees.rem_euclid(360) {
90 => (1.0, 0.0),
180 => (0.0, -1.0),
270 => (-1.0, 0.0),
_ => return m,
};
let (px, py) = (rect[0], rect[3]);
let t = [cos, sin, -sin, cos, px - px * cos + py * sin, py - px * sin - py * cos];
[
m[0] * t[0] + m[1] * t[2],
m[0] * t[1] + m[1] * t[3],
m[2] * t[0] + m[3] * t[2],
m[2] * t[1] + m[3] * t[3],
m[4] * t[0] + m[5] * t[2] + t[4],
m[4] * t[1] + m[5] * t[3] + t[5],
]
}
#[derive(Clone, Copy)]
enum Which {
/// Every comment and/or every form field.
@@ -194,6 +217,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
let mut gone_inline: HashSet<usize> = HashSet::new();
// Selected, but nothing was drawn for it: it stays, pop-up and reply handling included.
let mut undrawn: HashSet<ObjRef> = HashSet::new();
let rotation = page.dict.get(b"Rotate").map(|r| doc.resolve(r)).and_then(|r| r.as_int()).unwrap_or(0);
// A viewer draws many comments from their dictionary even when the file stores no
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
// flattening keeps a comment that still has ink on screen instead of deleting it.
@@ -230,6 +254,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
if let Some(m) = placement(doc, &form, rect) {
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
xobjects.set(name.into_bytes(), Object::Ref(ap));
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, EditError, Object, Rgb, added};
/// The kinds of page marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::marks::{MarkKind, matrix_number, n};
use super::*;
use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi};
pub(crate) fn page_list(doc: &Document) -> Vec<pdfcraft_model::Page> {
pdfcraft_model::pages(doc)
+175 -75
View File
@@ -507,6 +507,42 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
}
/// #2368: a widget with /NoRotate on a rotated page is shown upright, turned with the page's
/// /Rotate about its rectangle's upper-left corner (§12.5.3, as MuPDF and Poppler draw it).
/// Flattening drew its appearance at the rectangle as it is, so the widget turned sideways and
/// moved; a neighbour without the flag is unchanged, and so is everything on an unrotated page.
#[test]
fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() {
let page = stream("", " ");
let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 15 >>\nstream\n0 0 100 50 re f\nendstream";
for (rotate, plain, upright) in [
(0, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 1 0 0 1 100 500 cm /PCFl1 Do Q"),
(90, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 1 -1 0 150 550 cm /PCFl1 Do Q"),
(180, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q -1 0 0 -1 100 600 cm /PCFl1 Do Q"),
(270, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 -1 1 0 50 550 cm /PCFl1 Do Q"),
] {
let widget =
|flags: i64| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{flags}) /Rect [100 500 200 550] /F {flags} /AP << /N 7 0 R >> >>");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << >> /Contents 6 0 R /Annots [4 0 R 5 0 R] >>"
),
&widget(4),
&widget(4 | 16),
&page,
form,
]);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 2);
let doc = reopen(&doc);
let s = streams(&doc, 0);
let flat = s.last().unwrap();
assert!(flat.contains(plain), "/Rotate {rotate}: {flat}");
assert!(flat.contains(upright), "/Rotate {rotate}: {flat}");
}
}
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
#[test]
@@ -6664,12 +6700,124 @@ fn actual_text_pairs_past_the_marked_content_cap() {
}
}
/// #2992: the colour a page is painted with before anything else is its background, for exports
/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes
/// behind; a page that paints anything else first, a fill that doesn't cover the page, a
/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none.
fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
}
let xref_offset = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap()
}
fn assert_page_background_fill_cases(gs: &str) {
assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""),
Some([1.0, 0.0, 0.0]),
"one of the rectangles covers it"
);
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
// other way under nonzero.
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""),
Some([1.0, 0.0, 0.0]),
"nonzero, same winding: no hole"
);
assert_eq!(
page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""),
None,
"reversed by a mirroring cm"
);
assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""),
None,
"a page fill with a hole cut in it"
);
}
fn assert_page_background_transform_and_clip_cases() {
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
assert_eq!(
page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""),
None,
"a turned fill short of the corners"
);
assert_eq!(
page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned fill over them"
);
// The same for a clip.
assert_eq!(
page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
None,
"a turned clip"
);
assert_eq!(
page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned clip over the corners"
);
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
// doesn't.
assert_eq!(
page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
Some([1.0, 0.0, 0.0]),
"two covering clips"
);
assert_eq!(
page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
None,
"the second clip doesn't cover"
);
// Past 64 clipping rectangles the clip counts as unknown.
let clips = "0 0 600 800 re W n ".repeat(65);
assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
// A rectangle drawn the other way round (negative width) covers the page all the same.
assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
// edge of that included.
assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
}
/// #2992: page background detection follows the first full-page fill under text and ignores
/// partial, translucent, clipped, or unsupported fills.
#[test]
fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
let mut doc = fixture();
assert_eq!(page_background(&doc, 0).unwrap(), None, "text first");
add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap();
@@ -6680,77 +6828,8 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content");
assert!(page_background(&doc, 9).is_err());
let page = |content: &str, resources: &str| {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.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(b"xref\n0 5\n0000000000 65535 f \n");
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes());
page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap()
};
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it");
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
// other way under nonzero.
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole");
assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm");
assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it");
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners");
assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them");
// The same for a clip.
assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip");
assert_eq!(
page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned clip over the corners"
);
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
// doesn't.
assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips");
assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover");
// Past 64 clipping rectangles the clip counts as unknown.
let clips = "0 0 600 800 re W n ".repeat(65);
assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
// A rectangle drawn the other way round (negative width) covers the page all the same.
assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
// edge of that included.
assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
assert_page_background_fill_cases(gs);
assert_page_background_transform_and_clip_cases();
}
#[test]
@@ -7457,3 +7536,24 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
}
/// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2
/// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the
/// text after `Q` is found, and stays, where it is drawn.
#[test]
fn q_inside_a_text_object_restores_the_text_matrices() {
for (content, text, baseline) in [
("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]),
("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]),
// `T*` moves from the restored line, not the one set inside q/Q.
("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]),
] {
let mut doc = text_page(content);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}");
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use super::interp::{Carry, interpret};
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
use super::*;
use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions};
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
/// A region may contain several source fonts, sizes, colours and weights.
+1 -1
View File
@@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock};
use super::fallback::MAX_TYPE3_GLYPHS;
use super::lines::{page_dict, text_lines};
use super::rewrite::rewrite_block_from;
use super::*;
use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession};
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
/// are never installed, copied into assets, or used as a font for another document.
+3 -1
View File
@@ -1,6 +1,8 @@
use super::interp::Ts;
use super::lines::page_dict;
use super::*;
use super::{
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
};
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
+291 -254
View File
@@ -1,6 +1,6 @@
use super::fallback::is_type3_fallback;
use super::rewrite::vertical_advance;
use super::*;
use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc};
/// Text and graphics state captured for rewriting.
#[derive(Clone, Debug, PartialEq)]
@@ -425,7 +425,10 @@ fn positioned_tracking(shown: &mut [Shown]) {
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
pub(super) struct Carry {
pub(super) ts: Ts,
pub(super) stack: Vec<Ts>,
/// Each saved state with 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.
pub(super) stack: Vec<(Ts, Matrix, Matrix)>,
pub(super) marked: Vec<Option<usize>>,
/// For each open marked-content sequence, its optional content group when it is `/OC`.
pub(super) layers: Vec<Option<Vec<u8>>>,
@@ -472,6 +475,280 @@ type CachedFont = (Rc<Metrics>, Option<String>, Result<Option<f64>, String>);
/// A `Do` met while interpreting: its operator index, XObject name and the state there.
type Invocation = (usize, Vec<u8>, Ts);
#[derive(Default)]
struct MarkedContent {
regions: Vec<Option<usize>>,
layers: Vec<Option<Vec<u8>>>,
actual_text: Vec<Option<(usize, usize, String)>>,
}
impl MarkedContent {
fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) {
// Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and
// inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() });
// A layer named in the resources is told apart by its name, an inline one by where it opens.
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
self.layers
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value {
Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()),
_ => None,
});
self.actual_text.push(logical.map(|text| (shown_count, index, text)));
}
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
self.regions.pop();
self.layers.pop();
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
let tail = shown.split_off(start.min(shown.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
}
}
}
struct TextRunState<'a> {
ts: &'a mut Ts,
tm: &'a mut Matrix,
tlm: &'a mut Matrix,
at_bt: &'a (usize, Ts),
bt: usize,
marked: &'a MarkedContent,
carry: &'a mut Carry,
out: &'a mut Vec<Shown>,
}
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
let ts = &mut *state.ts;
let tm = &mut *state.tm;
let tlm = &mut *state.tlm;
let at_bt = state.at_bt;
let bt = state.bt;
let marked = state.marked;
let carry = &mut *state.carry;
let out = &mut *state.out;
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
*tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm);
*tm = *tlm;
}
let Some((name, m)) = ts.font.clone() else { return Ok(()) };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
// on it horizontally; `half` is half the widest glyph.
let vertical = ts.vertical;
let (mut y_text, mut half) = (0.0, 0.0_f64);
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
if let Some(w1) = vertical {
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
if !blank {
let ink_end = y_text + w1 * ts.size;
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
y_text += w1 * ts.size + spacing;
continue;
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
other => {
if let Some(n) = other.as_f64() {
if vertical.is_some() {
// Down by n/1000 em, not scaled by `Tz`.
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
}
y_text += dy;
continue;
}
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
}
}
*tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box from `a` to `b` along the run: from descent to ascent across it, or for
// vertical text, the widest glyph's width centred on the pen.
let span = |a: f64, b: f64| match vertical {
Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)],
None => [
trm0.apply(a, ts.rise + m.descent * ts.size),
trm0.apply(b, ts.rise + m.descent * ts.size),
trm0.apply(a, ts.rise + m.ascent * ts.size),
trm0.apply(b, ts.rise + m.ascent * ts.size),
],
};
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
// The same corners along and across the baseline: the run's own box, not its
// upright bounding box turned (which is wider by the box's height on a slant).
let dir = direction(&trm0);
let frame = corners
.iter()
.map(|&p| to_frame(dir, p))
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
return Ok(());
}
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
// preview divides by size × `Tz`, so `Tz` is put back here.
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
tracking: 0.0,
tm: Matrix([
tm.0[0],
tm.0[1],
tm.0[2],
tm.0[3],
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
]),
state: ts.clone(),
tlm: *tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
frame,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
end_y: end.1,
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
visual_order: m.visual_order,
decodable,
scope: marked.regions.last().copied().flatten(),
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
});
Ok(())
}
pub(super) fn interpret(
doc: &Document,
ops: &[Op],
@@ -487,74 +764,25 @@ pub(super) fn interpret(
let mut out: Vec<Shown> = Vec::new();
let mut ts = carry.ts.clone();
let mut at_bt = (0usize, ts.clone());
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack);
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
let mut bt = 0usize;
let mut marked = std::mem::take(&mut carry.marked);
let mut layers = std::mem::take(&mut carry.layers);
let mut actual: Vec<Option<(usize, usize, String)>> = Vec::new();
let mut marked =
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
for (i, op) in ops.iter().enumerate() {
match op.op.as_slice() {
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
// sequence (#3573). Both stacks stay bounded by the number of operators.
b"BMC" | b"BDC" => {
// Marked text paragraphs/headings are authoritative boundaries. Ignore
// artifacts and inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
// A layer named in the resources is told apart by its name, an inline one by where it opens.
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec)));
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
_ => None,
});
actual.push(logical.map(|s| (out.len(), i, s)));
marked.begin(doc, op, i, logical_text, out.len());
}
b"EMC" => {
marked.pop();
layers.pop();
if let Some(Some((start, open, logical))) = actual.pop() {
let tail = out.split_off(start.min(out.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for shown in items {
first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count);
first.extra_ops.push(shown.op);
first.extra_ops.extend(shown.extra_ops);
first.rect = [
first.rect[0].min(shown.rect[0]),
first.rect[1].min(shown.rect[1]),
first.rect[2].max(shown.rect[2]),
first.rect[3].max(shown.rect[3]),
];
first.frame = union(first.frame, shown.frame);
first.ink.extend(shown.ink);
}
first.extra_ops.extend([open, i]);
// Only right-to-left ActualText is already in typing order and must stay
// out of the line's bidi recovery. Word and Chrome wrap Latin ligatures
// (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
out.push(first);
}
}
marked.end(i, &mut out);
}
b"q" => stack.push(ts.clone()),
b"q" => stack.push((ts.clone(), tm, tlm)),
b"Q" => {
if let Some(s) = stack.pop() {
ts = s;
if let Some(saved) = stack.pop() {
(ts, tm, tlm) = saved;
}
}
b"cm" => {
@@ -650,207 +878,16 @@ pub(super) fn interpret(
tm = tlm;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm);
tm = tlm;
}
let Some((name, m)) = ts.font.clone() else { continue };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
// on it horizontally; `half` is half the widest glyph.
let vertical = ts.vertical;
let (mut y_text, mut half) = (0.0, 0.0_f64);
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank =
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
if let Some(w1) = vertical {
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
if !blank {
let ink_end = y_text + w1 * ts.size;
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
y_text += w1 * ts.size + spacing;
continue;
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
other => {
if let Some(n) = other.as_f64() {
if vertical.is_some() {
// Down by n/1000 em, not scaled by `Tz`.
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
}
y_text += dy;
continue;
}
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
}
}
tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box from `a` to `b` along the run: from descent to ascent across it, or for
// vertical text, the widest glyph's width centred on the pen.
let span = |a: f64, b: f64| match vertical {
Some(_) => {
[trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)]
}
None => [
trm0.apply(a, ts.rise + m.descent * ts.size),
trm0.apply(b, ts.rise + m.descent * ts.size),
trm0.apply(a, ts.rise + m.ascent * ts.size),
trm0.apply(b, ts.rise + m.ascent * ts.size),
],
};
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
let rect = corners
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
// The same corners along and across the baseline: the run's own box, not its
// upright bounding box turned (which is wider by the box's height on a slant).
let dir = direction(&trm0);
let frame = corners
.iter()
.map(|&p| to_frame(dir, p))
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
span(a, b)
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
continue;
}
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
// preview divides by size × `Tz`, so `Tz` is put back here.
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
tracking: 0.0,
tm: Matrix([
tm.0[0],
tm.0[1],
tm.0[2],
tm.0[3],
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
]),
state: ts.clone(),
tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
frame,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
end_y: end.1,
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
visual_order: m.visual_order,
decodable,
scope: marked.last().copied().flatten(),
paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
});
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
show_text(op, i, &mut state)?;
}
_ => {}
}
}
carry.ts = ts;
carry.stack = stack;
carry.marked = marked;
carry.layers = layers;
carry.marked = marked.regions;
carry.layers = marked.layers;
positioned_tracking(&mut out);
Ok(out)
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::fallback::{same_font, same_paint};
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
use super::*;
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
impl TextLine {
/// The baseline's height in user space.
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,11 @@
//! Geometry-based regions and mixed-style rewriting of existing text.
use super::*;
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family,
source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs,
};
#[path = "text_regions/grouping.rs"]
mod grouping;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3,7 +3,10 @@
//! another one's output; the commit writes into the document it was opened on, and refuses if
//! that document has changed since.
use super::*;
use super::{
BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from,
text_lines,
};
/// One paragraph being edited: the document as it was opened, and the page's lines then.
pub struct TextSession {
+3
View File
@@ -378,6 +378,8 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"),
// Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693).
c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"),
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
@@ -392,6 +394,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"),
c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"),
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize};
/// A change to an existing page image.
#[derive(Clone, Debug, PartialEq)]
+15
View File
@@ -237,6 +237,16 @@ impl Document {
self.generation
}
/// The styles the document's text is set in, the largest first, for bookmarks from text
/// styles (#1693).
/// Empty when the document doesn't allow copying: the samples are its text.
pub fn text_styles(&self) -> Vec<crate::text_style_bookmarks::TextStyleUse> {
if !self.allows_copying() {
return Vec::new();
}
self.editor.as_ref().map(|e| crate::text_style_bookmarks::styles(&e.cos)).unwrap_or_default()
}
/// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based).
pub fn text_lines(&self, page: usize) -> Vec<pdfcraft_edit::TextLine> {
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
@@ -248,6 +258,11 @@ impl Document {
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
}
/// Page properties ▸ Tab order: each page's tab order, in page order.
pub fn tab_orders(&self) -> Vec<TabOrder> {
self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).unwrap_or_default()
}
/// Add alternate text: the figures, in document order.
pub fn figures(&self) -> Vec<a11y::Figure> {
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
+13 -1
View File
@@ -122,6 +122,11 @@ pub enum Edit {
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
/// "Untitled" bookmark (New Bookmarks from Structure).
BookmarksFromStructure,
/// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests
/// under it, …), under a new first "Untitled" bookmark (#1693; see [`crate::text_style_bookmarks`]).
BookmarksFromTextStyles {
levels: Vec<crate::text_style_bookmarks::TextStyle>,
},
/// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels.
NumberPages {
from: usize,
@@ -287,6 +292,12 @@ pub enum Edit {
DeleteField {
name: String,
},
/// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button
/// of a group, or one copy of a duplicated field. The last widget takes the field with it.
DeleteWidget {
name: String,
widget: usize,
},
/// Page properties ▸ Tab order, for pages (0-based).
SetTabOrder {
pages: Vec<usize>,
@@ -517,7 +528,8 @@ pub enum Edit {
links: Vec<(usize, Vec<[f64; 4]>, String)>,
style: LinkStyle,
},
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text).
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the
/// comments of another PDF onto the pages with the same numbers.
ImportData {
name: String,
bytes: Arc<Vec<u8>>,
+4 -2
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table};
/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not
/// here: its wording is only the hole, because a batch carries its name as text (see
@@ -39,6 +39,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Colour",
"Style",
"New bookmarks from structure",
"New bookmarks from text styles",
"Number pages",
"Measure distance",
"Measure perimeter",
@@ -186,6 +187,7 @@ impl Edit {
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
Edit::NumberPages { .. } => Label::of("Number pages"),
// What was measured is part of the wording too, for the same reason.
Edit::AddMeasurement(m) => Label::of(match m.kind {
@@ -224,7 +226,7 @@ impl Edit {
Edit::ResetForm { .. } => Label::of("Clear form"),
Edit::AddField { .. } => Label::of("Add field"),
Edit::SetFieldProps { .. } => Label::of("Change field properties"),
Edit::DeleteField { .. } => Label::of("Delete field"),
Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"),
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
+79 -7
View File
@@ -1,4 +1,9 @@
use super::*;
use super::{
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
use crate::SourceProblem;
use crate::sources::{comment_source, import_pdf_comments};
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
@@ -29,6 +34,17 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
Err(EditError::NotPermitted("page changes"))
}
}
// Creates bookmarks (assembly), and their titles are the document's text, copied out of it
// (Table 22, bit 5), as an export of it would be.
Edit::BookmarksFromTextStyles { .. } => {
if !p.assemble() {
Err(EditError::NotPermitted("page changes"))
} else if !p.copy() {
Err(EditError::NotPermitted("copying text and images"))
} else {
Ok(())
}
}
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
@@ -103,6 +119,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::XfaToStatic
| Edit::SetFieldProps { .. }
| Edit::DeleteField { .. }
| Edit::DeleteWidget { .. }
| Edit::DuplicateField { .. }
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
@@ -277,10 +294,30 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj })
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None })
.collect();
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::BookmarksFromTextStyles { levels } => {
if levels.is_empty() {
return Err(EditError::Invalid("choose a text style for at least one bookmark level".into()));
}
// Each line goes to the first level whose style it is in: a style given again, or one
// within the size tolerance of an earlier level's, would leave its level empty.
for (i, a) in levels.iter().enumerate() {
if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) {
let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2));
return Err(EditError::Invalid(format!(
"bookmark levels {first} and {second} have the same text style: choose a different one for each level"
)));
}
}
let entries = crate::text_style_bookmarks::headings(doc, levels);
if entries.is_empty() {
return Err(EditError::Invalid("no text is set in the chosen styles".into()));
}
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
Edit::AddMeasurement(m) => {
measure::add(doc, m, &cx.meta())?;
@@ -328,7 +365,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
// the strikeout's `/NM` (#2666).
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?;
pdfcraft_annot::add_text_replacement(
doc,
pdfcraft_annot::TextReplacement {
page: *page,
quads,
replacement: text,
author,
strike,
caret,
strike_meta: &strike_meta,
caret_meta: &caret_meta,
},
)?;
}
Edit::MarkAnnotation { page, index, marked, author } => {
pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?;
@@ -338,7 +387,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
pdfcraft_annot::set_style(
doc,
*page,
*index,
pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() },
&cx.meta(),
)?;
}
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
@@ -425,6 +480,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
}
}
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?,
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
// As after a script edit: the values the new order gives show at once.
@@ -612,7 +668,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into())));
}
}
Edit::ImportData { bytes, .. } => {
Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => {
let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?;
import_pdf_comments(doc, &src)?;
}
Edit::ImportData { name, bytes } => {
// An XFA form takes XFA data files (XDP, datasets, or a data document) as its data;
// XFDF, FDF, Acrobat's XML and tables go the usual way.
let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten();
@@ -626,9 +686,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes));
xfa_after_data_change(doc, cx, true)?;
}
Some(Err(why)) => return Err(EditError::Invalid(why)),
Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)),
_ => {
pdfcraft_xfdf::import(doc, bytes)?;
// A PDF with bytes before its header, within the 1,024 the reader allows, is read
// as a PDF when it is not a data file. A file the PDF reader cannot open keeps
// the data importer's error.
let mut data = doc.clone();
match pdfcraft_xfdf::import(&mut data, bytes) {
Ok(_) => *doc = data,
Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) {
Ok(src) => import_pdf_comments(doc, &src)?,
Err(SourceProblem::Unreadable(_)) => return Err(e.into()),
Err(p) => return Err(EditError::Source(format!("{name}: {p}"))),
},
Err(e) => return Err(e.into()),
}
}
}
}
+6 -3
View File
@@ -7,14 +7,15 @@
//! off, scripts don't run and AF calls keep working natively.
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request};
use crate::{DocId, Edit, EditError, Session};
/// What scripts produced for the viewer since it last looked.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct JsOutput {
pub alerts: Vec<String>,
/// The messages scripts showed, in order, with their titles, icons and buttons.
pub alerts: Vec<Message>,
pub console: Vec<String>,
pub requests: Vec<Request>,
/// Script errors (as Acrobat's console shows them).
@@ -195,6 +196,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
}
impl Scripts for JsRunner {
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
/// asked for; a refusal carries its last message.
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
let states: Vec<_> = fields
.iter()
@@ -218,7 +221,7 @@ impl Scripts for JsRunner {
rc: o.rc,
value: o.value.clone(),
changes: changes(&states, &o.changed),
message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
}
}
}
+3 -2
View File
@@ -54,6 +54,7 @@ mod stamp_preview;
mod test_hooks;
pub mod text_fonts;
mod text_preview;
pub mod text_style_bookmarks;
pub mod word_count;
pub mod xfa;
mod xfa_form;
@@ -66,7 +67,7 @@ pub use links::{
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::StampPreview;
pub use text_preview::TextEditPreview;
pub use text_preview::{TextEditPreview, UnmarkedPages};
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
@@ -88,7 +89,7 @@ pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
calculations_changed as form_calculations_changed, flags as field_flags,
appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
+15 -13
View File
@@ -25,6 +25,17 @@ pub struct PreparedDocument {
document: Document,
}
struct DocumentOpenData<'a> {
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
info: DocInfo,
structure: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
render_password: Option<String>,
password: Option<&'a str>,
xfa: Option<XfaLayout>,
}
impl OpenContext {
/// Prepare a document without mutating the session that supplied this context.
pub fn prepare_open(
@@ -241,7 +252,8 @@ impl Session {
};
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
let mut document =
Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa });
// A script that ran away on open turns the form's scripts off for this document.
document.xfa_template = if ran_away { None } else { xfa_template };
document.js_output.append(script_output);
@@ -254,18 +266,8 @@ impl Session {
}
/// The last step of opening: build the document record and register it.
#[allow(clippy::too_many_arguments)]
fn build_document(
trust: &Arc<TrustStore>,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
mut info: DocInfo,
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
render_password: Option<String>,
password: Option<&str>,
xfa: Option<XfaLayout>,
) -> Document {
fn build_document(trust: &Arc<TrustStore>, data: DocumentOpenData<'_>) -> Document {
let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data;
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
+8 -1
View File
@@ -1,4 +1,11 @@
use super::*;
#[cfg(test)]
use super::check_refresh_panic;
use super::{
Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings,
render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts,
write_incremental, xfa, xfa_sync_datasets,
};
impl Session {
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
+5 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state,
source_kind, writable, write_full,
};
impl Session {
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard,
render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
};
impl Session {
/// The bytes to write for Save: an incremental update of the file as opened/last saved,
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort,
apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices,
};
impl Session {
pub fn new() -> Self {
+18
View File
@@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Optio
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
}
/// Import the comments of `src` onto the pages of `doc` with the same numbers.
pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> {
if pdfcraft_organize::import_comments(doc, src)? == 0 {
return Err(pdfcraft_xfdf::DataError::NothingImported.into());
}
Ok(())
}
/// Parse another PDF to import comments from, whatever its permissions allow.
pub(crate) fn comment_source(bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, SourceProblem> {
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) {
Ok(Ok(d)) => Ok(d),
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
}
}
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
+315 -11
View File
@@ -1849,6 +1849,153 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() {
assert!(s.get(id).unwrap().info.outline.is_empty());
}
/// Three pages whose headings are set apart only by how they look (#1693): chapters in
/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title
/// runs over two lines, its bold font is another subset of the same font, and one section is set
/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row.
fn styled_headings_fixture() -> Vec<u8> {
let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \
BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET";
let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \
BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \
BT /FB 14 Tf 72 600 Td (Section C) Tj ET";
// Two sections on one row, the right one drawn first: read from the left.
let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET";
let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>";
pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>"))
.obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>")
.stream("", page1)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>")
.stream("", page2)
.obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>")
.stream("", page3)
.build()
}
/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones:
/// chapters at level 1, sections nested under the chapter before them. A two-line title is one
/// bookmark; a subset of the same font and a size set by the text matrix count as the style.
/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused.
#[test]
fn bookmarks_from_text_styles_nest_the_headings_and_undo() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
let styles = s.get(id).unwrap().text_styles();
let listed: Vec<(String, f64, bool, usize, usize)> =
styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect();
assert_eq!(
listed,
[
("Helvetica-Bold".to_string(), 18.0, true, 3, 2),
("Helvetica-Bold".to_string(), 14.0, true, 5, 3),
("Helvetica".to_string(), 11.0, false, 3, 2),
]
);
assert_eq!(styles[0].sample, "Chapter One");
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap();
let outline = s.get(id).unwrap().info.outline.clone();
assert_eq!(
outline_titles(&outline),
["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"]
);
let first = &outline[0].children[0];
match first.view {
pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => {
assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}")
}
ref v => panic!("{v:?}"),
}
assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles"));
s.undo(id).unwrap();
assert!(s.get(id).unwrap().info.outline.is_empty());
// One level is enough; the sections are then left out.
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]);
s.undo(id).unwrap();
// Nothing chosen, or a style no text is in: refused, nothing changes.
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err();
assert!(err.to_string().contains("at least one bookmark level"), "{err}");
let unused = pdfcraft_engine_style(&section, 30.0);
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err();
assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style
/// for two levels (or one within the size tolerance of the other) would leave the later level
/// empty. Refused at the edit, for the dialog and automation alike; nothing changes.
#[test]
fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
let styles = s.get(id).unwrap().text_styles();
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
let near = pdfcraft_engine_style(&section, section.size + 0.1);
for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] {
let n = levels.len();
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err();
assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
}
/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document
/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction.
#[test]
fn bookmarks_from_text_styles_need_copying_permission() {
let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1 ^ 0b1_0000,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
let mut s = Session::new();
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying());
assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied");
let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false };
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err();
assert_eq!(err, EditError::NotPermitted("copying text and images"));
let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(!s.get(owner).unwrap().text_styles().is_empty());
assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may");
}
/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower,
/// are two bookmarks, not one heading run on across the columns.
#[test]
fn headings_in_two_columns_are_two_bookmarks() {
let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET";
let bytes = pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>")
.stream("", page)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
.build();
let mut s = Session::new();
let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap();
let style = s.get(id).unwrap().text_styles()[0].style.clone();
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]);
}
/// `style` at another size.
fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle {
text_style_bookmarks::TextStyle { size, ..style.clone() }
}
#[test]
fn number_pages_shows_in_the_viewer_and_undoes() {
let (mut s, id) = session_with(5);
@@ -3002,6 +3149,45 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() {
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background"));
}
/// Update previews its marks on the page without the ones it replaces: a yellow background
/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one.
/// The other kinds of mark and the page's own content stay, every page is prepared at once (the
/// dialog steps through them), a document without that kind has nothing to prepare, and
/// preparing changes nothing in the document.
#[test]
fn update_previews_on_the_page_without_the_marks_it_replaces() {
let (mut s, id) = session_with(2);
let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() };
s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap();
let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() };
s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap();
let doc = s.get(id).unwrap();
let (generation, dirty) = (doc.edit_generation(), doc.dirty);
let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] {
p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap()
};
let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count();
let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count();
assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background");
let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background");
let page = unmarked.render_page(0, 1.0).unwrap();
assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white");
assert!(reds(&page) > 50, "the watermark stays");
assert!(dark(&page) > 10, "the page text stays");
let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap();
assert_eq!(reds(&page), 0, "without the watermark");
assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays");
assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too");
assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages");
assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header");
assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10");
let doc = s.get(id).unwrap();
assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged");
}
#[test]
fn marker_indexes_match_after_open_and_save_when_requested() {
let (mut source, source_id) = session_with(1);
@@ -3068,7 +3254,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
assert!(doc.links().is_empty());
assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list");
}
metadata_and_comment_edits_keep_the_indexes(&mut session, id);
link_edits_rebuild_only_the_link_list(&mut session, id);
content_and_marker_edits_rebuild_the_markers(&mut session, id);
}
fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) {
session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap();
assert!(session.get(id).unwrap().links().is_empty());
assert!(session.get(id).unwrap().marks().is_empty());
@@ -3077,7 +3268,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap();
assert!(session.get(id).unwrap().links().is_empty());
assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index");
}
fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) {
session
.apply(
id,
@@ -3101,7 +3294,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap();
assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes");
assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes");
}
fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) {
session
.apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } })
.unwrap();
@@ -4799,6 +4994,8 @@ fn scripted_form_with(helpers: &str) -> Vec<u8> {
out
}
/// AcroForm field scripts validate, calculate and format through edits, and a button script's
/// changes, messages and requests come back.
#[test]
fn form_javascript_validates_calculates_and_formats() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@@ -4817,7 +5014,7 @@ fn form_javascript_validates_calculates_and_formats() {
// A button script changes fields in one undoable step, and asks for a page.
let o =
s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap();
assert_eq!(o.alerts, ["Done"]);
assert_eq!(o.alerts, [crate::js::Message::plain("Done")]);
assert_eq!(o.requests, [js::Request::Print]);
assert_eq!(value(&s, "total"), ["12"], "recalculated after the change");
assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only());
@@ -4881,17 +5078,17 @@ fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() {
assert!(s.take_js_output(id).alerts.is_empty());
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
assert!(out.error.is_none(), "{out:?}");
assert_eq!(out.alerts, ["Console message"]);
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]);
let out = s.run_javascript(id, "", Some("hello")).unwrap();
assert!(out.error.is_none(), "{out:?}");
assert_eq!(out.alerts, ["Hello 2"]);
assert_eq!(out.alerts, [js::Message::plain("Hello 2")]);
assert_eq!(out.requests, [js::Request::Print]);
// The same context rule applies to format/validate/calculate through JsRunner.
s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap();
s.take_js_output(id);
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert_eq!(s.take_js_output(id).alerts, ["Field message"]);
assert_eq!(s.take_js_output(id).alerts, [js::Message::plain("Field message")]);
// Saved XFA keeps its container scripts for inspection, but does not execute them.
let saved = s.get(id).unwrap().bytes.clone();
let reopened = s.open("saved.pdf", None, saved, None).unwrap();
@@ -4916,7 +5113,7 @@ fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts(
s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap();
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
assert!(out.error.is_none(), "{out:?}");
assert_eq!(out.alerts, ["Console message"]);
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
}
#[test]
@@ -4933,7 +5130,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() {
let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default());
assert!(out.error.is_none(), "{out:?}");
assert_eq!(out.result.as_deref(), Some("6"));
assert_eq!(out.alerts, ["PDF message"]);
assert_eq!(out.alerts, [js::Message::plain("PDF message")]);
}
}
@@ -6241,6 +6438,8 @@ fn repeated_xfa_print_buttons_run_their_click_script() {
}
}
/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows,
/// with undo and save/reopen.
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
@@ -6265,7 +6464,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
// A failing validate script shows its message; the value stays, as in Acrobat.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
assert_eq!(value(&s, "total"), vec!["2500".to_string()]);
// A button adds a row: new fields, same page, one undo step.
let before = names(&s).len();
@@ -6280,7 +6479,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
// Message boxes reach the viewer.
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
// The check box shows the hidden subform; undo hides it again.
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
@@ -6295,7 +6494,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
// FormCalc scripts run too.
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
assert_eq!(o.error, None);
assert_eq!(o.alerts, vec!["FormCalc".to_string()]);
assert_eq!(o.alerts, vec![crate::js::Message::plain("FormCalc")]);
// Saved and reopened: the visible subform, the values and the rows are what they were.
let saved = s.save_bytes(id).unwrap();
let id2 = s.open("again.pdf", None, saved, None).unwrap();
@@ -6516,6 +6715,7 @@ fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() {
assert_eq!(value(&s, id, "total"), vec!["15".to_string()]);
}
/// A dynamic XFA form scripted in FormCalc runs as the JavaScript one does.
#[test]
fn dynamic_xfa_forms_run_formcalc_scripts() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
@@ -6534,7 +6734,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
assert_eq!(value(&s, "qty"), vec!["500".to_string()]);
// Buttons: a row is added, summed and removed; the message box reaches the viewer.
let before = names(&s).len();
@@ -6545,7 +6745,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap();
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
s.run_javascript(id, "", Some("removeRow")).unwrap();
@@ -6680,6 +6880,8 @@ fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
.count()
}
/// Hostile scripts run on open are merged, capped, kept from printing, saving and opening links,
/// and noted.
#[test]
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
@@ -6711,6 +6913,36 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console);
}
/// An XFA button's message boxes reach the viewer with their titles, icons and buttons, from
/// JavaScript (`xfa.host.messageBox`, `app.alert` in its own order) and FormCalc alike, every
/// one of them and in order.
#[test]
fn xfa_message_boxes_keep_their_title_icon_and_buttons() {
use crate::js::{Message, MessageButtons, MessageIcon};
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"xfa.host.messageBox("Hello " + qty.rawValue);"#,
r#"xfa.host.messageBox("Clear the rows?", "Order form", 2, 2); app.alert("Saved", 3, 0, "Order form");"#,
)
.replace(r#"$host.messageBox("FormCalc")"#, r#"$host.messageBox("Too many", "Limits", 1, 1)"#);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("boxes.pdf", None, bytes, None).expect("opens");
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.error, None);
let titled = |text: &str, title: &str, icon, buttons| Message { text: text.into(), title: Some(title.into()), icon, buttons };
assert_eq!(
o.alerts,
vec![
titled("Clear the rows?", "Order form", MessageIcon::Question, MessageButtons::YesNo),
titled("Saved", "Order form", MessageIcon::Status, MessageButtons::Ok),
]
);
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
assert_eq!(o.error, None);
assert_eq!(o.alerts, vec![titled("Too many", "Limits", MessageIcon::Warning, MessageButtons::OkCancel)]);
}
#[test]
fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
// Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per
@@ -8106,7 +8338,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
// Redo now succeeds cleanly.
assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page");
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
rolls_back_metadata_scope(&mut s, id);
rolls_back_protection_keys(&mut s, id);
}
/// Undo/redo of a metadata edit rolled back after a refresh panic.
fn rolls_back_metadata_scope(s: &mut Session, id: DocId) {
// 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`.
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap();
let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false };
@@ -8124,7 +8361,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
assert_eq!(s.get(id).unwrap().info.title, None);
assert_eq!(s.redo(id).unwrap().as_str(), "Change Title");
assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft"));
}
/// Undo/redo of a protection edit rolled back after a refresh panic.
fn rolls_back_protection_keys(s: &mut Session, id: DocId) {
// 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting.
let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() };
s.apply(id, Edit::Protect(protect)).unwrap();
@@ -8696,6 +8936,70 @@ fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() {
assert!(blue.count() > 20, "the pilcrow is drawn beside the caret");
}
/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses
/// a PDF that has none.
#[test]
fn importing_a_pdf_copies_its_comments() {
let mut s = Session::new();
let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap();
let review = pdf_with_annots(&[
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>",
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>",
]);
s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap();
let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::<Vec<_>>();
assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link");
assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf");
assert!(s.get(id).unwrap().info.annotations.is_empty());
// Bytes before the header, as the reader allows them.
let prefixed = [b"\r\n \n".as_slice(), &review].concat();
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
assert_eq!(contents(&s), [Some("from the review".to_string())]);
s.undo(id).unwrap();
let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]);
let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err();
assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string());
// A PDF that needs a password is refused with that reason, with or without bytes before its header.
let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap();
locked
.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "u",
owner_password: "o",
permissions: 0,
encrypt_metadata: true,
seed: [1; 32],
})
.unwrap();
let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap();
for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] {
let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err();
assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string());
}
// An XFDF file that mentions the PDF header is still read as XFDF.
let xfdf = r#"<?xml version="1.0"?><!-- exported from a file starting %PDF-1.7 --><xfdf xmlns="http://ns.adobe.com/xfdf/"><annots><text page="0" name="n1" rect="10,10,30,30"><contents>from the data file</contents></text></annots></xfdf>"#;
s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap();
assert_eq!(contents(&s), [Some("from the data file".to_string())]);
}
/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of
/// refusing it as a data file in an unknown encoding.
#[test]
fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() {
let data = "<form><page1><country>JP</country></page1></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]);
let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat();
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect();
assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}");
}
/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …).
fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect();
+93 -8
View File
@@ -1,4 +1,5 @@
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone.
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and,
//! the same way, the page beneath a dragged added item or the marks an Update replaces.
use std::sync::Arc;
@@ -18,18 +19,75 @@ impl TextEditPreview {
}
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
render_one(&self.bytes, &self.config, self.page, scale)
}
}
/// A document's pages without its marks of one kind ([`Document::without_marks`]).
pub struct UnmarkedPages {
bytes: Arc<Vec<u8>>,
config: RenderConfig,
/// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried
/// again), and kept: parsing costs about as much as reading the document.
parsed: Option<PageRenderer>,
/// How many times it was parsed.
#[cfg(test)]
builds: u64,
}
impl UnmarkedPages {
pub fn renderer(&self) -> RenderPool {
RenderPool::new(self.bytes.clone(), 1, self.config.clone())
}
/// `page` at `scale`, on this thread.
pub fn render_page(&mut self, page: usize, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone());
let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
if self.parsed.is_none() {
self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone()));
#[cfg(test)]
{
self.builds += 1;
}
Ok(out)
})?
}
let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) };
let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() }));
let out = match out {
Ok(out) => out,
Err(e) => {
// A panic may have left it half-way: parse afresh next time.
self.parsed = None;
return Err(e);
}
};
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
}
/// How many times [`Self::render_page`] has parsed the document.
#[cfg(test)]
pub(crate) fn parser_builds(&self) -> u64 {
self.builds
}
}
/// `page` of `bytes` at `scale`, on this thread.
fn render_one(bytes: &Arc<Vec<u8>>, config: &RenderConfig, page: usize, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = PageRenderer::new(bytes.clone(), config.clone());
let out = renderer.render(RenderRequest { page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
impl Document {
@@ -90,6 +148,33 @@ impl Document {
})?
}
/// The document without its marks of `kind`, on every page: what Update replaces them on, so
/// its preview doesn't draw the new marks over the old ones (or tint the old background).
/// Made once for all the pages a dialog steps through, as cloning and writing the document
/// costs about a save. `None` when the document has none, and the thumbnails already are
/// those pages. Read-only, like [`Self::text_edit_preview`].
pub fn without_marks(&self, kind: crate::MarkKind) -> Result<Option<UnmarkedPages>, String> {
if !self.marks().contains(&kind) {
return Ok(None);
}
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
let n = pdfcraft_model::pages(&cos).len();
if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::<Vec<_>>(), kind).map_err(|e| e.to_string())? == 0 {
return Ok(None);
}
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(Some(UnmarkedPages {
bytes,
config: self.config.clone(),
parsed: None,
#[cfg(test)]
builds: 0,
}))
})?
}
/// An added item alone, on a transparent page: what follows the pointer while it is dragged,
/// so the page under its first place doesn't come along. Read-only, like
/// [`Self::added_content_preview`].

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