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

This commit is contained in:
oracola91 committed 2026-10-29 23:33:13 +02:00
commit ff3a300862
383 files changed
+30866 -7917

No files matched your search

+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: 51 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

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#12a58a"/><path fill="#efe9dc" fill-rule="evenodd" d="M101.8 505.4C68.7 502.1 39.1 483.6 21.7 455.2C13.3 441.6 8.2 426.1 6.6 409.8C6.1 404.5 6.1 340 6.6 339.6C6.7 339.5 7.3 339.2 7.9 339C9.3 338.5 10.9 337.4 11.7 336.3C12.9 334.6 13.9 333.7 16.2 332.1C19.5 330 20 329.8 25.2 328.1C29.9 326.7 31.9 325.9 33.5 324.8C34 324.5 35.3 323.9 36.4 323.5C38.5 322.7 44.3 319.3 45.5 318.1C46.3 317.4 46.7 315.8 46.7 314.1C46.7 313 47.2 310.8 47.8 308.8C48 308.3 49 306.6 50.2 305C53.3 300.9 54.3 299.4 55.6 296.8C56.3 295.4 57.3 293.7 57.9 292.8C59.5 290.5 59.3 289.5 57 288.4C55 287.4 54.2 284.9 54.7 281.7C55 280.2 55 279.8 54.6 278.8C54.2 277.8 54.1 276.8 54.1 272.6C54.1 266.9 54.1 266.7 52.2 266.2C51.6 266.1 51.1 265.7 50.8 265.3C49.9 263.8 47.8 261.7 46.6 260.8C44 259.2 43.9 257.7 45.8 255C46.5 254 47.4 252.7 47.6 252.2C48.3 250.9 49.3 250.1 51.1 249.2C52.9 248.4 53.4 247.8 53.6 246.1C53.7 245.1 53.9 244.7 54.7 244C55.3 243.5 56.5 242.4 57.5 241.5C59.8 239.5 60.4 239.2 66.4 237.9C74.1 236.1 76 235.2 76 233.4C76 233 76.1 232.5 76.2 232.4C76.3 232.2 77 231 77.8 229.6C79.1 227.1 79.1 227 78.6 226.5C77.5 225.2 78.8 221.8 81.8 218.6C84.2 216 85.5 214.1 85.5 213.2C85.5 212.8 85.7 211.9 86 211.2C86.4 210.1 86.5 209.3 86.4 207.1C86.4 204.4 86.4 204.4 85.8 204.4C85.5 204.5 85.2 204.3 85.1 204.1C84.9 203.5 84.6 203.7 84 204.6C82.3 207.5 82.6 201.7 84.5 195.5C84.9 194.1 85.4 192.2 85.5 191.3C85.7 190.4 85.9 189.4 86.2 188.9C86.4 188.4 86.6 187.3 86.7 185.7C86.8 182.5 87.9 180 90.4 177.7C93.3 174.8 94.5 173 94.4 171.9C94.4 170.9 94.3 170.9 93.4 170.8C91.2 170.5 92.2 168.7 95.1 167.6C96.2 167.2 98.2 165.7 98.2 165.2C98.2 165.1 97.7 165 97.1 165L95.9 164.9 95.9 162.9C95.9 161.1 96 160.7 96.6 159.7C96.9 159.1 97.2 158.4 97.2 158.2C97.3 158 97.7 157.3 98.2 156.6C99.5 154.9 101.1 151.9 101.7 150.2C102.2 148.8 104 146.3 105.4 145C106.9 143.5 107.9 139.9 107.1 138.6C106.7 138 106.5 138 105.5 138C104.2 138.1 104.2 138.3 105.1 135C106.2 131.1 106.1 130.5 104.3 131.1C103.5 131.4 103.6 130.9 105 127.9C106.5 124.7 108.3 121.7 110 119.4C110.7 118.4 111.5 117.1 111.7 116.4C112.8 113.4 114.7 110.7 117.2 108.6C118.5 107.5 119 107 119.4 106C119.9 104.7 121.3 103 121.9 103C122 103 122.7 102.3 123.4 101.4C124.6 100 124.7 99.7 124.8 98.1C125.1 95.8 127 93.3 130.1 91.2C130.5 90.9 131 90.3 131.3 89.9C131.9 88.8 132.9 88.2 135.1 87.5C137.1 86.9 137.7 86.4 138.9 84.6C139.4 83.8 139.9 83.2 140.1 83.2C140.6 83.2 142.8 81.3 142.8 80.9C142.8 80.5 144.1 79.3 144.6 79.2C145.3 79.2 147.8 76.9 149.4 74.8C151.2 72.4 152 71.7 154.4 71.1C155.4 70.9 156.1 70.5 157 69.7C158.9 67.8 160.9 67 164 66.6C165.5 66.5 166 66.2 166 65.5C166 64.6 167.1 63.8 168.9 63.3C169.8 63 172.1 62.2 173.8 61.4C175.6 60.6 177.8 59.8 178.7 59.7C179.9 59.5 180.2 59.3 180.2 59C180.2 58 182.7 57.5 186.9 57.5C191.1 57.6 191.8 57.3 193.5 54.9C194.7 53.2 197.3 52.2 200.9 52.1C203.3 52 203.8 51.9 204.1 51.5C204.4 51.2 204.9 51 205.5 51C206.2 51 206.6 50.8 207 50.3C207.4 50 208.2 49.3 208.9 48.8C209.6 48.4 210.4 47.7 210.7 47.3L211.2 46.5 214 46.7L216.7 46.8 217.4 46C217.7 45.6 218 45.1 218 45C218 44.8 218.4 44.5 218.8 44.3C219.2 44 219.7 43.5 219.9 43.2C220.1 42.8 220.3 42.6 220.8 42.7C223.4 43 223.8 43 224.2 42.7C224.5 42.4 225 42.3 225.6 42.3C226.5 42.4 226.6 42.3 227 41.6C227.4 40.7 227.6 38.4 227.3 38.1C226.3 37.5 227.5 36 228.9 36C230.6 36 236.5 38.5 239.6 40.4C241.5 41.6 243.2 42.3 243.6 42C244.5 41.4 246.2 41 248.7 41C250.2 40.9 251.6 40.8 251.8 40.7C252 40.6 253.4 40.4 254.9 40.2C259.3 39.6 260.9 38.6 260.6 36.8C260.3 34.8 261.6 33.7 264.4 33.6C265.9 33.5 266.3 33.6 266.8 34C267.4 34.5 267.4 34.5 267.9 34C268.3 33.6 268.8 33.4 270.3 33.4C272.1 33.4 272.3 33.4 275.1 34.8C276.7 35.6 278.3 36.5 278.7 36.8C279.2 37.3 279.6 37.4 280 37.3C280.6 37.2 280.9 37.5 282.4 39.1C284.2 41.1 284.7 41.3 285.3 40.5C285.5 40.1 285.8 40 286.1 40.1C286.7 40.3 287.9 42.6 288.7 44.9C289.6 47.7 289.6 47.7 291.6 46.2C295.5 43.1 298.8 42.1 298.8 44C298.8 45.4 299.6 45.5 302.3 44.4C306.9 42.5 308.3 42.3 308.8 43.4C309 43.9 309.1 44 310.3 44C312.1 44 311.9 44.6 309.7 45.8C308.7 46.2 307.8 46.9 307.6 47.2C307.4 47.5 306.6 48.1 305.9 48.6C304.5 49.6 302.7 51.5 302.9 51.8C303 51.9 303.4 51.9 303.8 51.7C304.3 51.6 305.5 51.4 306.7 51.3C308.7 51.2 308.7 51.2 309.1 51.8C309.7 52.7 310.3 52.7 312 51.9C312.8 51.5 314.7 50.7 316.4 50C318 49.3 320.4 48.4 321.5 47.9C324.6 46.7 325.8 46.7 324.9 48.1C324.4 49 324.4 49 325.7 48.9C327.9 48.8 327.4 49.7 323.4 53C322 54.2 321.8 54.5 321.8 55.2C321.7 56.4 322.3 56.7 323.6 56.1C324.9 55.6 329 55.3 330.7 55.7C333.1 56.2 335.5 57.5 336 58.5C336.6 59.8 337.6 60.1 340.4 59.7C343.1 59.4 344.5 59.6 345.2 60.4C345.9 61.1 345.7 61.5 344.5 62.4C343.1 63.4 343.3 63.8 345.2 64C347.1 64.2 349.1 65.2 349.2 65.9C349.3 66.6 350.1 67.3 350.9 67.3C351.8 67.2 353.2 67.9 353.4 68.4C353.5 68.6 353.3 69 353 Line truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#b70228"/><path fill="#efe9dc" fill-rule="evenodd" d="M101.8 505.4C68.7 502.1 39.1 483.6 21.7 455.2C13.3 441.6 8.2 426.1 6.6 409.8C6.1 404.5 6.1 340 6.6 339.6C6.7 339.5 7.3 339.2 7.9 339C9.3 338.5 10.9 337.4 11.7 336.3C12.9 334.6 13.9 333.7 16.2 332.1C19.5 330 20 329.8 25.2 328.1C29.9 326.7 31.9 325.9 33.5 324.8C34 324.5 35.3 323.9 36.4 323.5C38.5 322.7 44.3 319.3 45.5 318.1C46.3 317.4 46.7 315.8 46.7 314.1C46.7 313 47.2 310.8 47.8 308.8C48 308.3 49 306.6 50.2 305C53.3 300.9 54.3 299.4 55.6 296.8C56.3 295.4 57.3 293.7 57.9 292.8C59.5 290.5 59.3 289.5 57 288.4C55 287.4 54.2 284.9 54.7 281.7C55 280.2 55 279.8 54.6 278.8C54.2 277.8 54.1 276.8 54.1 272.6C54.1 266.9 54.1 266.7 52.2 266.2C51.6 266.1 51.1 265.7 50.8 265.3C49.9 263.8 47.8 261.7 46.6 260.8C44 259.2 43.9 257.7 45.8 255C46.5 254 47.4 252.7 47.6 252.2C48.3 250.9 49.3 250.1 51.1 249.2C52.9 248.4 53.4 247.8 53.6 246.1C53.7 245.1 53.9 244.7 54.7 244C55.3 243.5 56.5 242.4 57.5 241.5C59.8 239.5 60.4 239.2 66.4 237.9C74.1 236.1 76 235.2 76 233.4C76 233 76.1 232.5 76.2 232.4C76.3 232.2 77 231 77.8 229.6C79.1 227.1 79.1 227 78.6 226.5C77.5 225.2 78.8 221.8 81.8 218.6C84.2 216 85.5 214.1 85.5 213.2C85.5 212.8 85.7 211.9 86 211.2C86.4 210.1 86.5 209.3 86.4 207.1C86.4 204.4 86.4 204.4 85.8 204.4C85.5 204.5 85.2 204.3 85.1 204.1C84.9 203.5 84.6 203.7 84 204.6C82.3 207.5 82.6 201.7 84.5 195.5C84.9 194.1 85.4 192.2 85.5 191.3C85.7 190.4 85.9 189.4 86.2 188.9C86.4 188.4 86.6 187.3 86.7 185.7C86.8 182.5 87.9 180 90.4 177.7C93.3 174.8 94.5 173 94.4 171.9C94.4 170.9 94.3 170.9 93.4 170.8C91.2 170.5 92.2 168.7 95.1 167.6C96.2 167.2 98.2 165.7 98.2 165.2C98.2 165.1 97.7 165 97.1 165L95.9 164.9 95.9 162.9C95.9 161.1 96 160.7 96.6 159.7C96.9 159.1 97.2 158.4 97.2 158.2C97.3 158 97.7 157.3 98.2 156.6C99.5 154.9 101.1 151.9 101.7 150.2C102.2 148.8 104 146.3 105.4 145C106.9 143.5 107.9 139.9 107.1 138.6C106.7 138 106.5 138 105.5 138C104.2 138.1 104.2 138.3 105.1 135C106.2 131.1 106.1 130.5 104.3 131.1C103.5 131.4 103.6 130.9 105 127.9C106.5 124.7 108.3 121.7 110 119.4C110.7 118.4 111.5 117.1 111.7 116.4C112.8 113.4 114.7 110.7 117.2 108.6C118.5 107.5 119 107 119.4 106C119.9 104.7 121.3 103 121.9 103C122 103 122.7 102.3 123.4 101.4C124.6 100 124.7 99.7 124.8 98.1C125.1 95.8 127 93.3 130.1 91.2C130.5 90.9 131 90.3 131.3 89.9C131.9 88.8 132.9 88.2 135.1 87.5C137.1 86.9 137.7 86.4 138.9 84.6C139.4 83.8 139.9 83.2 140.1 83.2C140.6 83.2 142.8 81.3 142.8 80.9C142.8 80.5 144.1 79.3 144.6 79.2C145.3 79.2 147.8 76.9 149.4 74.8C151.2 72.4 152 71.7 154.4 71.1C155.4 70.9 156.1 70.5 157 69.7C158.9 67.8 160.9 67 164 66.6C165.5 66.5 166 66.2 166 65.5C166 64.6 167.1 63.8 168.9 63.3C169.8 63 172.1 62.2 173.8 61.4C175.6 60.6 177.8 59.8 178.7 59.7C179.9 59.5 180.2 59.3 180.2 59C180.2 58 182.7 57.5 186.9 57.5C191.1 57.6 191.8 57.3 193.5 54.9C194.7 53.2 197.3 52.2 200.9 52.1C203.3 52 203.8 51.9 204.1 51.5C204.4 51.2 204.9 51 205.5 51C206.2 51 206.6 50.8 207 50.3C207.4 50 208.2 49.3 208.9 48.8C209.6 48.4 210.4 47.7 210.7 47.3L211.2 46.5 214 46.7L216.7 46.8 217.4 46C217.7 45.6 218 45.1 218 45C218 44.8 218.4 44.5 218.8 44.3C219.2 44 219.7 43.5 219.9 43.2C220.1 42.8 220.3 42.6 220.8 42.7C223.4 43 223.8 43 224.2 42.7C224.5 42.4 225 42.3 225.6 42.3C226.5 42.4 226.6 42.3 227 41.6C227.4 40.7 227.6 38.4 227.3 38.1C226.3 37.5 227.5 36 228.9 36C230.6 36 236.5 38.5 239.6 40.4C241.5 41.6 243.2 42.3 243.6 42C244.5 41.4 246.2 41 248.7 41C250.2 40.9 251.6 40.8 251.8 40.7C252 40.6 253.4 40.4 254.9 40.2C259.3 39.6 260.9 38.6 260.6 36.8C260.3 34.8 261.6 33.7 264.4 33.6C265.9 33.5 266.3 33.6 266.8 34C267.4 34.5 267.4 34.5 267.9 34C268.3 33.6 268.8 33.4 270.3 33.4C272.1 33.4 272.3 33.4 275.1 34.8C276.7 35.6 278.3 36.5 278.7 36.8C279.2 37.3 279.6 37.4 280 37.3C280.6 37.2 280.9 37.5 282.4 39.1C284.2 41.1 284.7 41.3 285.3 40.5C285.5 40.1 285.8 40 286.1 40.1C286.7 40.3 287.9 42.6 288.7 44.9C289.6 47.7 289.6 47.7 291.6 46.2C295.5 43.1 298.8 42.1 298.8 44C298.8 45.4 299.6 45.5 302.3 44.4C306.9 42.5 308.3 42.3 308.8 43.4C309 43.9 309.1 44 310.3 44C312.1 44 311.9 44.6 309.7 45.8C308.7 46.2 307.8 46.9 307.6 47.2C307.4 47.5 306.6 48.1 305.9 48.6C304.5 49.6 302.7 51.5 302.9 51.8C303 51.9 303.4 51.9 303.8 51.7C304.3 51.6 305.5 51.4 306.7 51.3C308.7 51.2 308.7 51.2 309.1 51.8C309.7 52.7 310.3 52.7 312 51.9C312.8 51.5 314.7 50.7 316.4 50C318 49.3 320.4 48.4 321.5 47.9C324.6 46.7 325.8 46.7 324.9 48.1C324.4 49 324.4 49 325.7 48.9C327.9 48.8 327.4 49.7 323.4 53C322 54.2 321.8 54.5 321.8 55.2C321.7 56.4 322.3 56.7 323.6 56.1C324.9 55.6 329 55.3 330.7 55.7C333.1 56.2 335.5 57.5 336 58.5C336.6 59.8 337.6 60.1 340.4 59.7C343.1 59.4 344.5 59.6 345.2 60.4C345.9 61.1 345.7 61.5 344.5 62.4C343.1 63.4 343.3 63.8 345.2 64C347.1 64.2 349.1 65.2 349.2 65.9C349.3 66.6 350.1 67.3 350.9 67.3C351.8 67.2 353.2 67.9 353.4 68.4C353.5 68.6 353.3 69 353 Line truncated

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

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#12a58a"/><path fill="#efe9dc" fill-rule="evenodd" d="M103 505C57 502 18 468 8 421L7 415 7 377L6 340 8 339C9 339 11 337 13 335C16 332 19 330 26 328C31 326 43 321 45 319C46 317 46 317 47 313C48 309 48 308 51 304C53 302 55 298 56 297C56 295 58 293 58 293C59 291 59 290 58 289C55 287 55 287 54 273C54 268 54 267 53 267C51 266 44 259 45 258C45 256 49 251 51 249C53 249 53 248 53 247C54 243 59 240 64 238C73 236 76 235 76 234C76 233 77 232 77 230C79 228 79 227 79 225C78 223 79 221 83 217C86 214 86 213 86 208C86 205 85 202 84 204C84 206 83 205 83 203C83 202 84 198 86 190C86 189 87 187 87 185C87 182 88 180 91 177C95 173 95 172 93 171C92 170 92 169 96 167C98 166 98 165 97 165C95 164 96 159 99 156C99 155 101 153 101 151C102 150 103 147 105 146C108 142 108 138 105 138C104 138 104 138 105 135C106 132 106 130 105 131C103 131 106 124 110 120C111 119 112 117 112 115C113 114 114 112 116 110C118 108 120 106 120 106C120 105 121 104 122 103C124 101 125 100 125 98C125 95 132 88 135 88C136 88 138 86 140 84C142 81 145 79 146 79C147 78 148 76 149 75C151 73 152 72 154 71C155 71 157 70 158 69C159 68 160 67 162 67C164 67 166 66 166 65C167 64 169 63 177 60C179 60 180 59 181 58C181 58 183 58 187 58L192 57 193 55C195 53 198 52 201 52C203 52 204 52 204 51C204 51 205 51 205 51C206 51 210 48 211 47C211 47 212 47 214 46C217 46 217 46 218 45C220 43 220 43 223 43C227 43 228 42 227 38L227 36 229 36C231 36 233 37 237 39C241 41 243 42 244 42C244 42 247 41 249 41C258 40 261 39 261 37C260 34 265 32 267 34C267 34 267 34 268 34C270 33 277 35 279 37C279 37 279 37 280 37C280 37 282 38 283 39C284 41 285 41 285 41C286 39 288 42 289 45C289 48 290 48 291 46C294 44 298 42 299 44C299 45 300 45 304 44C307 43 308 43 308 43C309 44 310 44 310 44C311 44 311 45 310 46C304 49 302 52 306 51C307 51 308 51 309 52C310 53 310 53 318 49C324 47 325 47 325 48C325 49 325 49 326 49C328 49 327 50 324 52C321 55 321 57 324 56C328 55 334 56 336 59C337 60 338 60 341 60C345 59 346 60 345 62C343 64 344 64 346 64C346 64 348 65 349 65C350 66 351 67 352 67C353 68 353 68 353 69C353 71 353 71 359 70C364 70 365 71 362 73C358 74 360 75 364 74C367 73 367 73 367 75C367 77 368 78 370 78C371 79 372 80 372 82C372 83 373 84 373 85C375 86 377 87 377 86C378 86 378 86 378 87C378 88 378 89 379 89C383 90 384 91 386 94L387 96 392 96C397 95 397 95 398 97C399 98 399 98 402 98C406 97 407 98 408 100C408 101 408 102 410 102C414 103 418 107 418 110C418 111 419 113 420 115C422 119 422 120 422 122C422 123 422 124 422 124C422 124 423 126 423 128C423 130 424 132 425 133C427 136 427 145 425 151C424 152 424 154 423 155C423 157 422 158 421 159C420 160 419 161 419 162C419 165 415 169 412 171C411 172 410 173 410 173C410 174 409 175 408 176C407 177 407 177 407 180C407 181 406 183 406 183C405 185 405 186 407 188C409 191 410 192 410 194C410 196 410 197 413 200C417 205 420 211 421 218C423 222 422 224 421 222C419 219 419 221 420 225C422 228 421 230 420 227C417 223 417 226 420 236C423 243 423 245 425 247C426 248 427 250 427 251C428 254 427 259 426 257C424 254 424 259 425 262C427 266 427 270 425 274C422 282 424 289 434 300C438 305 439 308 439 309C439 310 439 311 440 313C441 315 441 317 440 318C440 318 440 320 442 324C444 330 446 334 446 337C446 340 446 340 444 339C442 338 442 338 442 342C442 343 443 346 443 348C443 351 444 352 446 357C449 361 449 362 449 364C449 365 449 366 450 367C451 369 450 370 449 369C446 366 446 369 449 372C451 375 453 381 453 383C453 384 454 385 456 387C460 391 465 403 464 403C464 403 463 403 463 403C463 403 462 405 462 407C461 412 460 413 459 412C458 411 458 411 457 412C457 413 457 416 457 419C456 425 457 425 458 428C458 429 458 431 458 432C458 432 458 434 459 435C459 436 460 437 459 438C459 440 459 440 461 441C464 444 465 449 463 448C461 448 460 450 460 451C460 451 460 452 460 453C459 454 460 455 462 457C465 461 466 465 466 470C466 473 467 476 467 478L468 481 466 483C453 494 429 504 408 505C400 506 110 506 103 505Z"/><path fill="#0b0b0c" fill-rule="evenodd" d="M107 505C107 504 108 499 109 498C109 497 110 498 109 502C108 505 108 507 107 505ZM110 504C110 503 111 502 111 502C112 500 112 503 111 505L110 506 110 504ZM118 505C118 504 119 501 120 499C121 496 122 494 122 493C122 491 122 492 121 495C120 498 119 499 119 497C119 496 119 496 118 499C117 501 117 502 116 502C116 503 116 497 117 493C118 489 118 484 117 485C117 485 116 487 116 488C114 498 113 498 114 488C114 481 114 480 113 482C113 487 112 487 112 482L112 477 111 481C111 486 110 488 110 482C111 478 110 477 109 480C109 484 108 482 108 478C109 477 109 475 109 474L108 472 108 474L107 476 107 473C108 469 107 469 106 472C105 475 104 474 105 470C106 469 106 466 106 466L106 464 106 466C104 471 103 465 104 458C104 457 104 456 104 456C104 455 104 456 101 462C100 465 100 463 101 458C101 455 102 452 102 450C102 446 101 448 100 453C100 458 98 461 98 458C98 4Line truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#b70228"/><path fill="#efe9dc" fill-rule="evenodd" d="M103 505C57 502 18 468 8 421L7 415 7 377L6 340 8 339C9 339 11 337 13 335C16 332 19 330 26 328C31 326 43 321 45 319C46 317 46 317 47 313C48 309 48 308 51 304C53 302 55 298 56 297C56 295 58 293 58 293C59 291 59 290 58 289C55 287 55 287 54 273C54 268 54 267 53 267C51 266 44 259 45 258C45 256 49 251 51 249C53 249 53 248 53 247C54 243 59 240 64 238C73 236 76 235 76 234C76 233 77 232 77 230C79 228 79 227 79 225C78 223 79 221 83 217C86 214 86 213 86 208C86 205 85 202 84 204C84 206 83 205 83 203C83 202 84 198 86 190C86 189 87 187 87 185C87 182 88 180 91 177C95 173 95 172 93 171C92 170 92 169 96 167C98 166 98 165 97 165C95 164 96 159 99 156C99 155 101 153 101 151C102 150 103 147 105 146C108 142 108 138 105 138C104 138 104 138 105 135C106 132 106 130 105 131C103 131 106 124 110 120C111 119 112 117 112 115C113 114 114 112 116 110C118 108 120 106 120 106C120 105 121 104 122 103C124 101 125 100 125 98C125 95 132 88 135 88C136 88 138 86 140 84C142 81 145 79 146 79C147 78 148 76 149 75C151 73 152 72 154 71C155 71 157 70 158 69C159 68 160 67 162 67C164 67 166 66 166 65C167 64 169 63 177 60C179 60 180 59 181 58C181 58 183 58 187 58L192 57 193 55C195 53 198 52 201 52C203 52 204 52 204 51C204 51 205 51 205 51C206 51 210 48 211 47C211 47 212 47 214 46C217 46 217 46 218 45C220 43 220 43 223 43C227 43 228 42 227 38L227 36 229 36C231 36 233 37 237 39C241 41 243 42 244 42C244 42 247 41 249 41C258 40 261 39 261 37C260 34 265 32 267 34C267 34 267 34 268 34C270 33 277 35 279 37C279 37 279 37 280 37C280 37 282 38 283 39C284 41 285 41 285 41C286 39 288 42 289 45C289 48 290 48 291 46C294 44 298 42 299 44C299 45 300 45 304 44C307 43 308 43 308 43C309 44 310 44 310 44C311 44 311 45 310 46C304 49 302 52 306 51C307 51 308 51 309 52C310 53 310 53 318 49C324 47 325 47 325 48C325 49 325 49 326 49C328 49 327 50 324 52C321 55 321 57 324 56C328 55 334 56 336 59C337 60 338 60 341 60C345 59 346 60 345 62C343 64 344 64 346 64C346 64 348 65 349 65C350 66 351 67 352 67C353 68 353 68 353 69C353 71 353 71 359 70C364 70 365 71 362 73C358 74 360 75 364 74C367 73 367 73 367 75C367 77 368 78 370 78C371 79 372 80 372 82C372 83 373 84 373 85C375 86 377 87 377 86C378 86 378 86 378 87C378 88 378 89 379 89C383 90 384 91 386 94L387 96 392 96C397 95 397 95 398 97C399 98 399 98 402 98C406 97 407 98 408 100C408 101 408 102 410 102C414 103 418 107 418 110C418 111 419 113 420 115C422 119 422 120 422 122C422 123 422 124 422 124C422 124 423 126 423 128C423 130 424 132 425 133C427 136 427 145 425 151C424 152 424 154 423 155C423 157 422 158 421 159C420 160 419 161 419 162C419 165 415 169 412 171C411 172 410 173 410 173C410 174 409 175 408 176C407 177 407 177 407 180C407 181 406 183 406 183C405 185 405 186 407 188C409 191 410 192 410 194C410 196 410 197 413 200C417 205 420 211 421 218C423 222 422 224 421 222C419 219 419 221 420 225C422 228 421 230 420 227C417 223 417 226 420 236C423 243 423 245 425 247C426 248 427 250 427 251C428 254 427 259 426 257C424 254 424 259 425 262C427 266 427 270 425 274C422 282 424 289 434 300C438 305 439 308 439 309C439 310 439 311 440 313C441 315 441 317 440 318C440 318 440 320 442 324C444 330 446 334 446 337C446 340 446 340 444 339C442 338 442 338 442 342C442 343 443 346 443 348C443 351 444 352 446 357C449 361 449 362 449 364C449 365 449 366 450 367C451 369 450 370 449 369C446 366 446 369 449 372C451 375 453 381 453 383C453 384 454 385 456 387C460 391 465 403 464 403C464 403 463 403 463 403C463 403 462 405 462 407C461 412 460 413 459 412C458 411 458 411 457 412C457 413 457 416 457 419C456 425 457 425 458 428C458 429 458 431 458 432C458 432 458 434 459 435C459 436 460 437 459 438C459 440 459 440 461 441C464 444 465 449 463 448C461 448 460 450 460 451C460 451 460 452 460 453C459 454 460 455 462 457C465 461 466 465 466 470C466 473 467 476 467 478L468 481 466 483C453 494 429 504 408 505C400 506 110 506 103 505Z"/><path fill="#0b0b0c" fill-rule="evenodd" d="M107 505C107 504 108 499 109 498C109 497 110 498 109 502C108 505 108 507 107 505ZM110 504C110 503 111 502 111 502C112 500 112 503 111 505L110 506 110 504ZM118 505C118 504 119 501 120 499C121 496 122 494 122 493C122 491 122 492 121 495C120 498 119 499 119 497C119 496 119 496 118 499C117 501 117 502 116 502C116 503 116 497 117 493C118 489 118 484 117 485C117 485 116 487 116 488C114 498 113 498 114 488C114 481 114 480 113 482C113 487 112 487 112 482L112 477 111 481C111 486 110 488 110 482C111 478 110 477 109 480C109 484 108 482 108 478C109 477 109 475 109 474L108 472 108 474L107 476 107 473C108 469 107 469 106 472C105 475 104 474 105 470C106 469 106 466 106 466L106 464 106 466C104 471 103 465 104 458C104 457 104 456 104 456C104 455 104 456 101 462C100 465 100 463 101 458C101 455 102 452 102 450C102 446 101 448 100 453C100 458 98 461 98 458C98 4Line truncated

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

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#12a58a"/><path fill="#efe9dc" fill-rule="evenodd" d="M101.8 505.4C68.7 502.1 39.1 483.6 21.7 455.2C13.3 441.6 8.2 426.1 6.6 409.8C6.1 404.5 6.1 340 6.6 339.6C6.7 339.5 7.3 339.2 7.9 339C9.3 338.5 10.9 337.4 11.7 336.3C12.9 334.6 13.9 333.7 16.2 332.1C19.5 330 20 329.8 25.2 328.1C29.9 326.7 31.9 325.9 33.5 324.8C34 324.5 35.3 323.9 36.4 323.5C38.5 322.7 44.3 319.3 45.5 318.1C46.3 317.4 46.7 315.8 46.7 314.1C46.7 313 47.2 310.8 47.8 308.8C48 308.3 49 306.6 50.2 305C53.3 300.9 54.3 299.4 55.6 296.8C56.3 295.4 57.3 293.7 57.9 292.8C59.5 290.5 59.3 289.5 57 288.4C55 287.4 54.2 284.9 54.7 281.7C55 280.2 55 279.8 54.6 278.8C54.2 277.8 54.1 276.8 54.1 272.6C54.1 266.9 54.1 266.7 52.2 266.2C51.6 266.1 51.1 265.7 50.8 265.3C49.9 263.8 47.8 261.7 46.6 260.8C44 259.2 43.9 257.7 45.8 255C46.5 254 47.4 252.7 47.6 252.2C48.3 250.9 49.3 250.1 51.1 249.2C52.9 248.4 53.4 247.8 53.6 246.1C53.7 245.1 53.9 244.7 54.7 244C55.3 243.5 56.5 242.4 57.5 241.5C59.8 239.5 60.4 239.2 66.4 237.9C74.1 236.1 76 235.2 76 233.4C76 233 76.1 232.5 76.2 232.4C76.3 232.2 77 231 77.8 229.6C79.1 227.1 79.1 227 78.6 226.5C77.5 225.2 78.8 221.8 81.8 218.6C84.2 216 85.5 214.1 85.5 213.2C85.5 212.8 85.7 211.9 86 211.2C86.4 210.1 86.5 209.3 86.4 207.1C86.4 204.4 86.4 204.4 85.8 204.4C85.5 204.5 85.2 204.3 85.1 204.1C84.9 203.5 84.6 203.7 84 204.6C82.3 207.5 82.6 201.7 84.5 195.5C84.9 194.1 85.4 192.2 85.5 191.3C85.7 190.4 85.9 189.4 86.2 188.9C86.4 188.4 86.6 187.3 86.7 185.7C86.8 182.5 87.9 180 90.4 177.7C93.3 174.8 94.5 173 94.4 171.9C94.4 170.9 94.3 170.9 93.4 170.8C91.2 170.5 92.2 168.7 95.1 167.6C96.2 167.2 98.2 165.7 98.2 165.2C98.2 165.1 97.7 165 97.1 165L95.9 164.9 95.9 162.9C95.9 161.1 96 160.7 96.6 159.7C96.9 159.1 97.2 158.4 97.2 158.2C97.3 158 97.7 157.3 98.2 156.6C99.5 154.9 101.1 151.9 101.7 150.2C102.2 148.8 104 146.3 105.4 145C106.9 143.5 107.9 139.9 107.1 138.6C106.7 138 106.5 138 105.5 138C104.2 138.1 104.2 138.3 105.1 135C106.2 131.1 106.1 130.5 104.3 131.1C103.5 131.4 103.6 130.9 105 127.9C106.5 124.7 108.3 121.7 110 119.4C110.7 118.4 111.5 117.1 111.7 116.4C112.8 113.4 114.7 110.7 117.2 108.6C118.5 107.5 119 107 119.4 106C119.9 104.7 121.3 103 121.9 103C122 103 122.7 102.3 123.4 101.4C124.6 100 124.7 99.7 124.8 98.1C125.1 95.8 127 93.3 130.1 91.2C130.5 90.9 131 90.3 131.3 89.9C131.9 88.8 132.9 88.2 135.1 87.5C137.1 86.9 137.7 86.4 138.9 84.6C139.4 83.8 139.9 83.2 140.1 83.2C140.6 83.2 142.8 81.3 142.8 80.9C142.8 80.5 144.1 79.3 144.6 79.2C145.3 79.2 147.8 76.9 149.4 74.8C151.2 72.4 152 71.7 154.4 71.1C155.4 70.9 156.1 70.5 157 69.7C158.9 67.8 160.9 67 164 66.6C165.5 66.5 166 66.2 166 65.5C166 64.6 167.1 63.8 168.9 63.3C169.8 63 172.1 62.2 173.8 61.4C175.6 60.6 177.8 59.8 178.7 59.7C179.9 59.5 180.2 59.3 180.2 59C180.2 58 182.7 57.5 186.9 57.5C191.1 57.6 191.8 57.3 193.5 54.9C194.7 53.2 197.3 52.2 200.9 52.1C203.3 52 203.8 51.9 204.1 51.5C204.4 51.2 204.9 51 205.5 51C206.2 51 206.6 50.8 207 50.3C207.4 50 208.2 49.3 208.9 48.8C209.6 48.4 210.4 47.7 210.7 47.3L211.2 46.5 214 46.7L216.7 46.8 217.4 46C217.7 45.6 218 45.1 218 45C218 44.8 218.4 44.5 218.8 44.3C219.2 44 219.7 43.5 219.9 43.2C220.1 42.8 220.3 42.6 220.8 42.7C223.4 43 223.8 43 224.2 42.7C224.5 42.4 225 42.3 225.6 42.3C226.5 42.4 226.6 42.3 227 41.6C227.4 40.7 227.6 38.4 227.3 38.1C226.3 37.5 227.5 36 228.9 36C230.6 36 236.5 38.5 239.6 40.4C241.5 41.6 243.2 42.3 243.6 42C244.5 41.4 246.2 41 248.7 41C250.2 40.9 251.6 40.8 251.8 40.7C252 40.6 253.4 40.4 254.9 40.2C259.3 39.6 260.9 38.6 260.6 36.8C260.3 34.8 261.6 33.7 264.4 33.6C265.9 33.5 266.3 33.6 266.8 34C267.4 34.5 267.4 34.5 267.9 34C268.3 33.6 268.8 33.4 270.3 33.4C272.1 33.4 272.3 33.4 275.1 34.8C276.7 35.6 278.3 36.5 278.7 36.8C279.2 37.3 279.6 37.4 280 37.3C280.6 37.2 280.9 37.5 282.4 39.1C284.2 41.1 284.7 41.3 285.3 40.5C285.5 40.1 285.8 40 286.1 40.1C286.7 40.3 287.9 42.6 288.7 44.9C289.6 47.7 289.6 47.7 291.6 46.2C295.5 43.1 298.8 42.1 298.8 44C298.8 45.4 299.6 45.5 302.3 44.4C306.9 42.5 308.3 42.3 308.8 43.4C309 43.9 309.1 44 310.3 44C312.1 44 311.9 44.6 309.7 45.8C308.7 46.2 307.8 46.9 307.6 47.2C307.4 47.5 306.6 48.1 305.9 48.6C304.5 49.6 302.7 51.5 302.9 51.8C303 51.9 303.4 51.9 303.8 51.7C304.3 51.6 305.5 51.4 306.7 51.3C308.7 51.2 308.7 51.2 309.1 51.8C309.7 52.7 310.3 52.7 312 51.9C312.8 51.5 314.7 50.7 316.4 50C318 49.3 320.4 48.4 321.5 47.9C324.6 46.7 325.8 46.7 324.9 48.1C324.4 49 324.4 49 325.7 48.9C327.9 48.8 327.4 49.7 323.4 53C322 54.2 321.8 54.5 321.8 55.2C321.7 56.4 322.3 56.7 323.6 56.1C324.9 55.6 329 55.3 330.7 55.7C333.1 56.2 335.5 57.5 336 58.5C336.6 59.8 337.6 60.1 340.4 59.7C343.1 59.4 344.5 59.6 345.2 60.4C345.9 61.1 345.7 61.5 344.5 62.4C343.1 63.4 343.3 63.8 345.2 64C347.1 64.2 349.1 65.2 349.2 65.9C349.3 66.6 350.1 67.3 350.9 67.3C351.8 67.2 353.2 67.9 353.4 68.4C353.5 68.6 353.3 69 353 Line truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#b70228"/><path fill="#efe9dc" fill-rule="evenodd" d="M101.8 505.4C68.7 502.1 39.1 483.6 21.7 455.2C13.3 441.6 8.2 426.1 6.6 409.8C6.1 404.5 6.1 340 6.6 339.6C6.7 339.5 7.3 339.2 7.9 339C9.3 338.5 10.9 337.4 11.7 336.3C12.9 334.6 13.9 333.7 16.2 332.1C19.5 330 20 329.8 25.2 328.1C29.9 326.7 31.9 325.9 33.5 324.8C34 324.5 35.3 323.9 36.4 323.5C38.5 322.7 44.3 319.3 45.5 318.1C46.3 317.4 46.7 315.8 46.7 314.1C46.7 313 47.2 310.8 47.8 308.8C48 308.3 49 306.6 50.2 305C53.3 300.9 54.3 299.4 55.6 296.8C56.3 295.4 57.3 293.7 57.9 292.8C59.5 290.5 59.3 289.5 57 288.4C55 287.4 54.2 284.9 54.7 281.7C55 280.2 55 279.8 54.6 278.8C54.2 277.8 54.1 276.8 54.1 272.6C54.1 266.9 54.1 266.7 52.2 266.2C51.6 266.1 51.1 265.7 50.8 265.3C49.9 263.8 47.8 261.7 46.6 260.8C44 259.2 43.9 257.7 45.8 255C46.5 254 47.4 252.7 47.6 252.2C48.3 250.9 49.3 250.1 51.1 249.2C52.9 248.4 53.4 247.8 53.6 246.1C53.7 245.1 53.9 244.7 54.7 244C55.3 243.5 56.5 242.4 57.5 241.5C59.8 239.5 60.4 239.2 66.4 237.9C74.1 236.1 76 235.2 76 233.4C76 233 76.1 232.5 76.2 232.4C76.3 232.2 77 231 77.8 229.6C79.1 227.1 79.1 227 78.6 226.5C77.5 225.2 78.8 221.8 81.8 218.6C84.2 216 85.5 214.1 85.5 213.2C85.5 212.8 85.7 211.9 86 211.2C86.4 210.1 86.5 209.3 86.4 207.1C86.4 204.4 86.4 204.4 85.8 204.4C85.5 204.5 85.2 204.3 85.1 204.1C84.9 203.5 84.6 203.7 84 204.6C82.3 207.5 82.6 201.7 84.5 195.5C84.9 194.1 85.4 192.2 85.5 191.3C85.7 190.4 85.9 189.4 86.2 188.9C86.4 188.4 86.6 187.3 86.7 185.7C86.8 182.5 87.9 180 90.4 177.7C93.3 174.8 94.5 173 94.4 171.9C94.4 170.9 94.3 170.9 93.4 170.8C91.2 170.5 92.2 168.7 95.1 167.6C96.2 167.2 98.2 165.7 98.2 165.2C98.2 165.1 97.7 165 97.1 165L95.9 164.9 95.9 162.9C95.9 161.1 96 160.7 96.6 159.7C96.9 159.1 97.2 158.4 97.2 158.2C97.3 158 97.7 157.3 98.2 156.6C99.5 154.9 101.1 151.9 101.7 150.2C102.2 148.8 104 146.3 105.4 145C106.9 143.5 107.9 139.9 107.1 138.6C106.7 138 106.5 138 105.5 138C104.2 138.1 104.2 138.3 105.1 135C106.2 131.1 106.1 130.5 104.3 131.1C103.5 131.4 103.6 130.9 105 127.9C106.5 124.7 108.3 121.7 110 119.4C110.7 118.4 111.5 117.1 111.7 116.4C112.8 113.4 114.7 110.7 117.2 108.6C118.5 107.5 119 107 119.4 106C119.9 104.7 121.3 103 121.9 103C122 103 122.7 102.3 123.4 101.4C124.6 100 124.7 99.7 124.8 98.1C125.1 95.8 127 93.3 130.1 91.2C130.5 90.9 131 90.3 131.3 89.9C131.9 88.8 132.9 88.2 135.1 87.5C137.1 86.9 137.7 86.4 138.9 84.6C139.4 83.8 139.9 83.2 140.1 83.2C140.6 83.2 142.8 81.3 142.8 80.9C142.8 80.5 144.1 79.3 144.6 79.2C145.3 79.2 147.8 76.9 149.4 74.8C151.2 72.4 152 71.7 154.4 71.1C155.4 70.9 156.1 70.5 157 69.7C158.9 67.8 160.9 67 164 66.6C165.5 66.5 166 66.2 166 65.5C166 64.6 167.1 63.8 168.9 63.3C169.8 63 172.1 62.2 173.8 61.4C175.6 60.6 177.8 59.8 178.7 59.7C179.9 59.5 180.2 59.3 180.2 59C180.2 58 182.7 57.5 186.9 57.5C191.1 57.6 191.8 57.3 193.5 54.9C194.7 53.2 197.3 52.2 200.9 52.1C203.3 52 203.8 51.9 204.1 51.5C204.4 51.2 204.9 51 205.5 51C206.2 51 206.6 50.8 207 50.3C207.4 50 208.2 49.3 208.9 48.8C209.6 48.4 210.4 47.7 210.7 47.3L211.2 46.5 214 46.7L216.7 46.8 217.4 46C217.7 45.6 218 45.1 218 45C218 44.8 218.4 44.5 218.8 44.3C219.2 44 219.7 43.5 219.9 43.2C220.1 42.8 220.3 42.6 220.8 42.7C223.4 43 223.8 43 224.2 42.7C224.5 42.4 225 42.3 225.6 42.3C226.5 42.4 226.6 42.3 227 41.6C227.4 40.7 227.6 38.4 227.3 38.1C226.3 37.5 227.5 36 228.9 36C230.6 36 236.5 38.5 239.6 40.4C241.5 41.6 243.2 42.3 243.6 42C244.5 41.4 246.2 41 248.7 41C250.2 40.9 251.6 40.8 251.8 40.7C252 40.6 253.4 40.4 254.9 40.2C259.3 39.6 260.9 38.6 260.6 36.8C260.3 34.8 261.6 33.7 264.4 33.6C265.9 33.5 266.3 33.6 266.8 34C267.4 34.5 267.4 34.5 267.9 34C268.3 33.6 268.8 33.4 270.3 33.4C272.1 33.4 272.3 33.4 275.1 34.8C276.7 35.6 278.3 36.5 278.7 36.8C279.2 37.3 279.6 37.4 280 37.3C280.6 37.2 280.9 37.5 282.4 39.1C284.2 41.1 284.7 41.3 285.3 40.5C285.5 40.1 285.8 40 286.1 40.1C286.7 40.3 287.9 42.6 288.7 44.9C289.6 47.7 289.6 47.7 291.6 46.2C295.5 43.1 298.8 42.1 298.8 44C298.8 45.4 299.6 45.5 302.3 44.4C306.9 42.5 308.3 42.3 308.8 43.4C309 43.9 309.1 44 310.3 44C312.1 44 311.9 44.6 309.7 45.8C308.7 46.2 307.8 46.9 307.6 47.2C307.4 47.5 306.6 48.1 305.9 48.6C304.5 49.6 302.7 51.5 302.9 51.8C303 51.9 303.4 51.9 303.8 51.7C304.3 51.6 305.5 51.4 306.7 51.3C308.7 51.2 308.7 51.2 309.1 51.8C309.7 52.7 310.3 52.7 312 51.9C312.8 51.5 314.7 50.7 316.4 50C318 49.3 320.4 48.4 321.5 47.9C324.6 46.7 325.8 46.7 324.9 48.1C324.4 49 324.4 49 325.7 48.9C327.9 48.8 327.4 49.7 323.4 53C322 54.2 321.8 54.5 321.8 55.2C321.7 56.4 322.3 56.7 323.6 56.1C324.9 55.6 329 55.3 330.7 55.7C333.1 56.2 335.5 57.5 336 58.5C336.6 59.8 337.6 60.1 340.4 59.7C343.1 59.4 344.5 59.6 345.2 60.4C345.9 61.1 345.7 61.5 344.5 62.4C343.1 63.4 343.3 63.8 345.2 64C347.1 64.2 349.1 65.2 349.2 65.9C349.3 66.6 350.1 67.3 350.9 67.3C351.8 67.2 353.2 67.9 353.4 68.4C353.5 68.6 353.3 69 353 Line truncated

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`].
Loaded 100 of 383 files, more files were not shown because too many files have changed in this diff. Show more