mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 17:01:23 +00:00
Merge main into release for PdfCraft v0.21.0
This commit is contained in:
commit
0ca2c549da
522 files changed
+78869
-4619
No files matched your search
@@ -0,0 +1,32 @@
|
||||
name: Nightly macOS
|
||||
|
||||
# The workspace's tests on macOS, each night and on demand. Pull requests and main run Linux and
|
||||
# Windows only (ci.yml): the org's 5 macOS runners are kept for release builds, and release.yml
|
||||
# builds and signs the macOS app without running the tests. This one leg a night finds a
|
||||
# macOS-only failure within a day, not at release time or from a user.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "41 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# The token is only used to check out the code: no job writes to the repository.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer run replaces one still queued or running.
|
||||
concurrency:
|
||||
group: nightly-macos-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
os: '["macos-latest"]'
|
||||
# Same craft-fonts commit as ci.yml, release.yml and freebsd.yml.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
# Every test, and all of their failures in one run. The whole workspace took 37 minutes on
|
||||
# macos-latest on 10 Nov 2026.
|
||||
command: cargo test --workspace --locked --no-fail-fast
|
||||
timeout-minutes: 90
|
||||
@@ -175,6 +175,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
|
||||
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `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`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
|
||||
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
|
||||
- **Page keys.** Page code (everything under `canvas/` and `tools/`, `app/keys.rs`, `app/clipboard.rs` and the Pages panel's `panels/pages.rs`) reads keys and clipboard events through `PageKeys` (`crates/ui-egui/src/canvas/page_keys.rs`), which has none while something is over the pages (a dialog, the palette, a popup, a menu: `PdfCraftApp::over_pages`), never egui's raw key state. The functions `NOT_PAGE_KEYS` lists, each with why, read raw: a window's or a field's own keys, which `PageKeys` would hide, and the few page reads it can't make (chords that wait for the menu bar instead, Escape ending what the pages hold, any key stopping a glide, the clipboard's hand-off to text fields). `page_pointer::tests::page_modules_read_keys_only_through_page_keys` 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.
|
||||
- **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
|
||||
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p <crate> --all-targets -- -D warnings` and `cargo test -p <crate>` to the crates you touched; the workspace sweep is CI's job.
|
||||
|
||||
+1
-1
@@ -275,7 +275,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
|
||||
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one |
|
||||
|
||||
|
||||
+1
-1
@@ -2235,7 +2235,7 @@ author = "John Slegers"
|
||||
source = "https://github.com/jslegers/emoji-icon-font"
|
||||
licence = "MIT"
|
||||
kind = "font"
|
||||
usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
|
||||
usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
|
||||
sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8"
|
||||
|
||||
[[asset]]
|
||||
|
||||
Generated
+125
-86
@@ -238,7 +238,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -321,7 +321,7 @@ dependencies = [
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -352,7 +352,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -378,7 +378,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -878,7 +878,7 @@ checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"polling",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"slab",
|
||||
"tracing",
|
||||
]
|
||||
@@ -902,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
|
||||
dependencies = [
|
||||
"calloop 0.14.4",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
@@ -1243,14 +1243,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=60ede99985ef1e5df87a2aca66ed538eb23dec99#60ede99985ef1e5df87a2aca66ed538eb23dec99"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree 0.21.2",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0)",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
@@ -1696,7 +1696,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
@@ -1725,10 +1725,14 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "edit-session"
|
||||
version = "0.1.0-alpha.2"
|
||||
|
||||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1766,7 +1770,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash",
|
||||
@@ -1787,7 +1791,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1806,7 +1810,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-winit"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
@@ -1833,7 +1837,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
@@ -1847,7 +1851,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -1860,7 +1864,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1903,7 +1907,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
@@ -1984,7 +1988,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -2011,7 +2015,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "epaint_default_fonts"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
@@ -2046,7 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2423,7 +2427,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -4168,7 +4172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4259,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4284,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4295,7 +4299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4307,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4318,7 +4322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4334,7 +4338,7 @@ dependencies = [
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tempfile",
|
||||
@@ -4342,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4353,18 +4357,18 @@ dependencies = [
|
||||
"pdfcraft-preflight",
|
||||
"pdfcraft-render",
|
||||
"png 0.18.1",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4372,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4389,7 +4393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-ccitt",
|
||||
@@ -4401,6 +4405,7 @@ dependencies = [
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"rustix 1.1.6",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-jpeg",
|
||||
@@ -4408,7 +4413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4416,11 +4421,12 @@ dependencies = [
|
||||
"sha2 0.11.1",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"icu_segmenter",
|
||||
@@ -4439,7 +4445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4483,14 +4489,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4503,7 +4509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4513,7 +4519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4526,11 +4532,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4538,7 +4544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4551,19 +4557,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
"png 0.18.1",
|
||||
"rten",
|
||||
"rten-imageproc",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tempfile",
|
||||
@@ -4572,7 +4579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr-worker"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"pdfcraft-ocr",
|
||||
@@ -4582,7 +4589,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4599,11 +4606,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4614,17 +4621,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
"rustix 1.1.5",
|
||||
"log",
|
||||
"rustix 1.1.6",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-prc"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4633,7 +4641,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-prc-render"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-prc",
|
||||
@@ -4642,7 +4650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4653,19 +4661,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"log",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"skrifa 0.44.0",
|
||||
"thiserror 2.0.21",
|
||||
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"pdfcraft-annot",
|
||||
@@ -4681,13 +4692,15 @@ dependencies = [
|
||||
"pdfcraft-testkit",
|
||||
"pdfcraft-xfa",
|
||||
"regex",
|
||||
"roxmltree 0.21.2",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4706,11 +4719,12 @@ dependencies = [
|
||||
"skrifa 0.44.0",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4744,15 +4758,16 @@ dependencies = [
|
||||
"sha2 0.11.1",
|
||||
"sha3",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
@@ -4765,6 +4780,7 @@ dependencies = [
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"edit-session",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4792,7 +4808,7 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"proptest",
|
||||
"rfd",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa 0.44.0",
|
||||
@@ -4805,11 +4821,12 @@ dependencies = [
|
||||
"web-time",
|
||||
"winsafe",
|
||||
"xattr",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -4824,7 +4841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4838,7 +4855,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-annot",
|
||||
@@ -5127,7 +5144,7 @@ dependencies = [
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5385,7 +5402,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5937,15 +5954,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6015,7 +6032,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6639,7 +6656,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"thiserror 2.0.21",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
@@ -6904,8 +6921,8 @@ dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"rustix 1.1.6",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7657,7 +7674,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
@@ -7670,7 +7687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
@@ -7692,7 +7709,7 @@ version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"wayland-client",
|
||||
"xcursor",
|
||||
]
|
||||
@@ -8030,7 +8047,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8307,7 +8324,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "winprint"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"fmt-derive",
|
||||
@@ -8325,6 +8342,28 @@ dependencies = [
|
||||
"xml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winprint"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"fmt-derive",
|
||||
"reqwest",
|
||||
"scopeguard",
|
||||
"sigstore-trust-root",
|
||||
"sigstore-types",
|
||||
"sigstore-verify",
|
||||
"strum",
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-implement",
|
||||
"windows-numerics",
|
||||
"xml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
@@ -8366,7 +8405,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
@@ -8424,7 +8463,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
@@ -8466,7 +8505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8573,7 +8612,7 @@ dependencies = [
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
@@ -8842,14 +8881,14 @@ dependencies = [
|
||||
[[patch.unused]]
|
||||
name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
+55
-52
@@ -5,13 +5,15 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Small, revision-bound UI drafts; the existing engine remains the owner of PDF edits/history.
|
||||
edit-session = { path = "vendor/edit-session", version = "=0.1.0-alpha.2" }
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
@@ -24,47 +26,47 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
|
||||
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" }
|
||||
# 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 = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.20.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.20.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.20.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.20.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.20.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.20.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.20.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.20.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.20.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.20.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.20.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.20.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.20.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.20.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.20.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.20.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.20.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.20.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.20.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.20.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.20.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.20.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.20.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.20.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.20.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.20.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.20.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.20.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.20.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.20.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.20.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.20.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.20.0", default-features = false }
|
||||
pdfcraft-prc = { path = "crates/prc", version = "0.20.0" }
|
||||
pdfcraft-prc-render = { path = "crates/prc-render", version = "0.20.0" }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.21.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.21.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.21.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.21.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.21.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.21.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.21.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.21.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.21.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.21.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.21.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.21.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.21.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.21.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.21.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.21.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.21.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.21.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.21.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.21.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.21.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.21.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.21.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.21.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.21.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.21.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.21.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.21.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.21.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.21.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.21.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.21.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.21.0", default-features = false }
|
||||
pdfcraft-prc = { path = "crates/prc", version = "0.21.0" }
|
||||
pdfcraft-prc-render = { path = "crates/prc-render", version = "0.21.0" }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
@@ -84,6 +86,7 @@ hayro-jbig2 = "0.3"
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
zeroize = "1"
|
||||
# Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives
|
||||
# the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it).
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] }
|
||||
@@ -149,22 +152,22 @@ 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).
|
||||
# 725a39fca940f77149ffbb9c58eef405a16997bc, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
|
||||
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
|
||||
# earlier patches and their source guards, not path patches; the other `vendor/` entries above
|
||||
# remain real path patches. See vendor/README.md and the migration plan.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
+13
-1
@@ -16,7 +16,19 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
|
||||
[[document]]
|
||||
source = "NOTICE"
|
||||
destination = "NOTICE"
|
||||
sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb"
|
||||
sha256 = "a7620386409bc691c7e223917db186ec447362f655fd81a1196d9853dcac1330"
|
||||
|
||||
[[document]]
|
||||
source = "vendor/edit-session/LICENSE-MIT"
|
||||
licences = ["MIT"]
|
||||
destination = "vendor/edit-session/LICENSE-MIT"
|
||||
sha256 = "665a942f9317a358930b470ad522a22a58ff6c346db0395463ec84dd3e19d775"
|
||||
|
||||
[[document]]
|
||||
source = "vendor/edit-session/LICENSE-APACHE"
|
||||
licences = ["Apache-2.0"]
|
||||
destination = "vendor/edit-session/LICENSE-APACHE"
|
||||
sha256 = "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
|
||||
|
||||
[[document]]
|
||||
source = "assets/app-icon/LICENSE-document.txt"
|
||||
|
||||
@@ -9,6 +9,8 @@ docs/brand/LICENSE-brand.txt.
|
||||
|
||||
Third-party material
|
||||
|
||||
- edit-session 0.1.0-alpha.2, Copyright (c) 2026 PerchAnton. See vendor/edit-session/LICENSE-MIT and vendor/edit-session/LICENSE-APACHE.
|
||||
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
|
||||
+90
-5
@@ -1,17 +1,28 @@
|
||||
- 2026-11-04 (M6, PR #5699 group/cache/MK/button review): Expand calculation groups/duplicates with streaming bounded lookup; retain one canonical Field object per exposed identity and prune stale roots. Protect outside shared-MK owners and carry exact button IDs through native clicks and js_run, with regression and persistence coverage.
|
||||
- 2026-11-04 (M6, PR #5699 early reset/duplicate identities): Enforce the shared 64-call reset budget before argument conversion/scanning, with sticky atomic rejection and Open/event budget sharing. Preserve PDF object identity in Calculate order, live targets and value writeback; regress duplicate names, reversed order, native completion and persistence.
|
||||
- 2026-11-04 (M6, PR #5699 independent review): Preserve unknown defaults through the JS reset shadow; bound menu/response text before copies. Cover native/console reset persistence, inherited defaults, decode-budget exhaustion and exact Unicode dialog boundaries.
|
||||
- 2026-11-04 (M6, PR #5699 memory/widget bounds): Count reset widgets and bound escaped UTF-8 text conversion before copies; add negative and Unicode boundary regressions.
|
||||
- 2026-11-04 (M6, PR #5699 reset estimate): Charge expanded-name scans and field-name copy/comparison bytes; add three negative regressions and byte-exact refusal checks.
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged)
|
||||
> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged)
|
||||
>
|
||||
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
|
||||
|
||||
|
||||
> **2026-11-04 (M6, PR #5699 reset budget):** Reject reset amplification before request copies, preview and PDF mutation; cap reset/selector counts and aggregate estimated work. Add native completion, boundary, flood and large-form regressions; preserve ordered under-budget resets.
|
||||
> **2026-11-04 (M6, PR #5699 dependent reset):** Reuse form reset on an isolated comparison document so unnamed built-in calculated text/combo fields are included; retain normal post-write calculation. Extend persisted-default and typed-response regressions.
|
||||
> **2026-11-04 (M6, PR #5699 reset ordering):** Reset live field values synchronously, commit resets before final script deltas using a reset baseline, and cover Doc Open reinitialization, save/reopen, Undo/Redo and replay-sensitive UI counters.
|
||||
> **2026-11-04 (M6, PR #5699 review):** Bound worker capacity and synchronous deadlines, isolate expired/pending realms before edits, keep script prompts above other dialogs, and cover block ASI, control-position regex literals, event routing and complete undo state. Validation is reported in the PR; parity remains partial.
|
||||
> **2026-11-03 (M6, PR #5699 follow-up):** Accept independent top-level statements separated by automatic semicolon insertion while retaining continuation-chain limits; assert cyclic calculation results and preserve upstream multiline Fill & Sign coverage.
|
||||
> **2026-11-02 (M6, PR #5699 second review):** Count active expression chains instead of whole-source tokens; align native button events and automation schemas, block document input under script prompts, and validate prompt dismissal and idle repaint scheduling.
|
||||
> **2026-11-02 (M6, PR #5699 review):** Cache per-edit script geometry/calculation plans, expose live document metadata, and bound persistent worker stacks to 64 MiB; add operation-count, lifecycle and isolated hostile-input/address-space regressions.
|
||||
> **2026-11-02 (M6, #5211/#269):** Integrate persistent native AcroForm realms, synchronous calculation, page boxes, appearance/geometry/icon writeback and native button prompt continuation. Synthetic workflow and cancellation tests added; validation details accompany the PR. Remaining JavaScript parity stays partial.
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
|
||||
@@ -28,6 +39,7 @@
|
||||
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.)
|
||||
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged)
|
||||
|
||||
@@ -36,10 +48,19 @@
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged)
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged)
|
||||
>
|
||||
>
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangle coordinates; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangles and appearance-state names; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-26 | **Last updated:** 2026-10-26 | **Change:** minor (M10: vector printing follows bounded annotation-value reference chains; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
@@ -66,6 +87,7 @@
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M10, gap 8: Office documents open through an installed LibreOffice; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -238,6 +260,26 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
|
||||
|
||||
- **2026-10-27 (M10, #5005):** Vector printing retains each annotation's optional-content membership when drawing its appearance.
|
||||
Shared appearances and existing layer state remain independent; estimates unchanged.
|
||||
|
||||
- **2026-10-29 (M10, #5029):** Vector printing also honours the implicit NoRotate behavior of existing Text appearances when the flag is clear.
|
||||
Print eligibility, source dictionaries and shared appearance streams remain unchanged.
|
||||
|
||||
- **2026-10-27 (M10, #5029):** Vector printing retains the orientation and upper-left anchor of existing NoRotate annotation appearances on rotated source pages.
|
||||
Crop origins, UserUnit and later sheet transforms remain effective; estimates unchanged.
|
||||
|
||||
- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices.
|
||||
Synthetic render and save/reopen regressions cover the change; estimates unchanged.
|
||||
|
||||
- **2026-10-31 (M6, PR #4166/#4088 integration):** Integrated main f137a289 (0.16.1), preserving upstream progress entries and automatic-field Print suppression with its console diagnostic. ShowPanel/SaveAs filtering, explicit button/console printing, field effects and document-error provenance remain intact, together with all three engine regressions and the headless automation regression. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
|
||||
|
||||
- **2026-10-29 (M6, PR #4166/#4088 integration):** Retained automatic-field Print suppression, ShowPanel/SaveAs filtering, explicit button/console printing and document-error provenance while integrating upstream 0.15. All three engine regressions and the headless regression remain in their current test modules, with matching parity evidence. Dependency manifests retain main's integrated craft-libs revision, which includes #366. Estimates unchanged.
|
||||
- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices.
|
||||
Synthetic render and save/reopen regressions cover the change; estimates unchanged.
|
||||
|
||||
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
|
||||
|
||||
- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `<appearance>` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft.
|
||||
@@ -280,6 +322,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
|
||||
- **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged.
|
||||
|
||||
- **2026-10-30 (M6):** Button scripts can read and change the normal push-button caption with `buttonGetCaption` and `buttonSetCaption`. The caption is drawn and retained through undo/redo and save/reopen; unsupported face indices are refused. Estimates unchanged.
|
||||
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
|
||||
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
|
||||
|
||||
@@ -290,6 +333,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
|
||||
|
||||
- **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
|
||||
- **2026-10-27 (M3, #3425):** A file named with open parameters, as help systems start a PDF reader (`manual.pdf#nameddest=Intro`, `#page=12` or `#Intro`), opens at that destination instead of failing to read `manual.pdf#…`: at launch, when another launch hands it over, and in its tab when it is already open. Other parameters are ignored. The app and `link_open` now also apply a GoToR link's named destination, looked up in the target's /Names /Dests or catalog /Dests, instead of warning that it wasn't applied; the app looks names up off the UI thread. `doc_open` reports the destination of a path with open parameters. Estimates unchanged.
|
||||
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
|
||||
|
||||
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
|
||||
@@ -298,7 +342,14 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
|
||||
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
|
||||
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
|
||||
- **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.
|
||||
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
|
||||
- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates are indirect, requiring exactly four finite resolved values.
|
||||
- **2026-10-26 (M10):** Vector printing follows up to 64 indirect references for annotation rectangles, coordinates and appearance-state names, rejecting cycles and longer chains without changing source objects.
|
||||
- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates or appearance-state names are indirect, requiring exactly four finite resolved rectangle values and a usable state name for state dictionaries.
|
||||
Synthetic CLI and source regressions cover the behavior.
|
||||
Estimates unchanged.
|
||||
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed.
|
||||
@@ -329,7 +380,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-22 (M6, #4088):** Automatic AcroForm Keystroke, Validate, Calculate and Format scripts cannot open the print dialog; ignored requests are reported in the JavaScript console. Explicit button and console printing remain available, including when they trigger automatic calculations. Engine and headless automation regressions cover the boundary. Estimates unchanged.
|
||||
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint<n>`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged.
|
||||
- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
|
||||
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
|
||||
@@ -441,11 +495,14 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
|
||||
- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged.
|
||||
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
|
||||
- **2026-10-17 (M10, gap 8):** File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files as new, unsaved PDFs, converted on the open worker by an installed LibreOffice (headless, a private temporary folder and fresh profile with macros off, stopped after 120 s); without LibreOffice the user is told it is needed. The Open dialog lists PDF (the default), Images, Office documents, Text and All files. Insert, Combine and Create ▸ Multiple files refuse Office documents for now, since they convert on the frame thread. `create.from-office` planned → partial. On 2026-10-26 the profile also stopped Writer and Calc from updating linked content (Impress has no such setting), and tests that skip without LibreOffice convert generated DOCX, XLSX, PPTX and RTF files through `doc_create_multiple` and `doc_combine` and check their text. Estimates unchanged.
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
|
||||
- **2026-10-15 (M10, print a selected area, #2051):** The area Take a Snapshot copies stays outlined with a small menu under it, Print area… and Save as image… (PNG). Print area… opens Print with Selected area (page N, size) under Pages to Print: the area prints alone, as a page of that size, with Fit, Actual size, Shrink or a custom scale, clipped to the area; Multiple, Booklet, Poster and Label stay for pages. `doc_print` takes `area`. New parity entry `print.selected-area` (shipped). Estimates unchanged.
|
||||
- **2026-10-15 (M7, find and replace in Edit text):** In Edit text mode the floating Find box gains a Replace with row (Edit ▸ Replace…, ⇧⌘H / Ctrl+Shift+H): Replace changes the highlighted match, Replace all every match as one undo step. Each match is rewritten inside its own paragraph through the paragraph rewrite, so fonts, sizes and colours stay and the paragraph rewraps; matches that span paragraphs, sit in OCR text, need another font or lie outside editable paragraphs are left alone and counted. `text_replace` drives the same plan. `edit.find-replace` planned → partial. Estimates unchanged.
|
||||
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
|
||||
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
|
||||
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
|
||||
@@ -456,6 +513,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
|
||||
- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged.
|
||||
- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged.
|
||||
- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
|
||||
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
|
||||
@@ -526,7 +584,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
|
||||
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
|
||||
- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file.
|
||||
- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
|
||||
- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
|
||||
- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`.
|
||||
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
|
||||
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
|
||||
@@ -586,6 +647,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-11-10 (M4, #4594):** Reconcile current main and keep the original draft-safety entry in this progress log instead of duplicating it in the shared roadmap header. Preserve inactive password cleanup and the Properties guard; behavior and estimates unchanged.
|
||||
- **2026-11-09 (M4, #4594):** Reconcile upstream changes in the license ledger, roadmap and UI test registry; retain the vendored draft guard, local interaction replay and upstream split-view tests. Behavior and estimates unchanged; maintainer review remains pending.
|
||||
|
||||
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
|
||||
- **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged.
|
||||
- **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native.
|
||||
@@ -816,14 +880,28 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
|
||||
- **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change.
|
||||
|
||||
- **2026-10-25 (M4, #4594 review):** Documented the session bridge and replay adapter; the stale notice now participates in translation-gap scanning, with a regression test. The original proposal depended on a shared release exception. The revised proposal vendors the Rust source and keeps the Python replay local. Maintainer review is pending.
|
||||
|
||||
- **2026-10-25 (M4):** Document Properties drafts now reject changed document identities/revisions, including reload and Undo/Redo, through pinned edit-session; real UI JSONL scenarios replay with interaction-check. Broader dialog invalidation remains separate (#3917); estimates unchanged.
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership.
|
||||
Source resources and shared appearances remain unchanged.
|
||||
**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
|
||||
|
||||
**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged.
|
||||
**2026-10-27 · minor:** M10: vector printing retains small appearance placement coefficients.
|
||||
Synthetic rendering and save/reopen regressions cover the change.
|
||||
Estimates unchanged.
|
||||
|
||||
**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
|
||||
|
||||
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. |
|
||||
| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. |
|
||||
| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. |
|
||||
| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. |
|
||||
@@ -831,8 +909,12 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
|
||||
| 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. Estimates unchanged. |
|
||||
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
|
||||
| 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. |
|
||||
| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. |
|
||||
| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. |
|
||||
| 2026-10-25 | minor | M10: vector printing resolves annotation rectangles and appearance-state names; synthetic save/reopen and print-content-mode evidence, estimates unchanged. |
|
||||
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
@@ -898,6 +980,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
|
||||
|
||||
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
|
||||
- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged.
|
||||
|
||||
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
|
||||
|
||||
@@ -916,3 +999,5 @@ PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape af
|
||||
PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged.
|
||||
|
||||
- **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending.
|
||||
|
||||
- 2026-11-04: AcroForm review follow-up covers field-instance retirement across history, rejected-writeback realm invalidation, mixed widget icons, shared calculation budgets and multi-select/checkbox consistency, frozen-global events, exact image imports, comparison-half scripts, focus reveal and explicit busy/timeout feedback. Synthetic regressions and elapsed UI deadlines accompany the fixes.
|
||||
@@ -6,12 +6,25 @@
|
||||
//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it
|
||||
//! into an error (for release builds).
|
||||
|
||||
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
|
||||
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
|
||||
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
|
||||
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
|
||||
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
|
||||
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
for dll in DELAY_LOADED {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
|
||||
}
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set("ProductName", "PdfCraft")
|
||||
.set("FileDescription", "PdfCraft command-line tool")
|
||||
|
||||
@@ -434,12 +434,21 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
|
||||
for warning in &p.warnings {
|
||||
match warning {
|
||||
pdfcraft_render::RenderWarning::ContentTruncated => {
|
||||
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"warning: page {page}: content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"
|
||||
);
|
||||
}
|
||||
pdfcraft_render::RenderWarning::ColourFallback => {
|
||||
let _ =
|
||||
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
|
||||
}
|
||||
pdfcraft_render::RenderWarning::MalformedIndexedImage => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"warning: page {page}: an image with a malformed Indexed colour table may be drawn wrongly or not at all"
|
||||
);
|
||||
}
|
||||
pdfcraft_render::RenderWarning::Type3WidthsMissing => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
#[path = "src/windows_manifest.rs"]
|
||||
mod windows_manifest;
|
||||
|
||||
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
|
||||
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
|
||||
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
|
||||
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
|
||||
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
|
||||
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico");
|
||||
@@ -18,6 +25,12 @@ fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
for dll in DELAY_LOADED {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
|
||||
}
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("../../assets/app-icon/pdfcraft.ico")
|
||||
.set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2")
|
||||
|
||||
@@ -475,6 +475,7 @@ fn app_creator<'a>(
|
||||
}
|
||||
app.set_startup_update_check(true);
|
||||
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
|
||||
app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided());
|
||||
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
||||
@@ -152,9 +152,20 @@ pub fn parse_request(line: &str) -> Option<(Vec<String>, bool)> {
|
||||
}
|
||||
|
||||
/// File arguments made absolute against this launch's working folder, which the running app
|
||||
/// doesn't share. A path that can't be made absolute is sent as it is.
|
||||
/// doesn't share. A path that can't be made absolute is sent as it is. Of a file named with open
|
||||
/// parameters (`manual.pdf#nameddest=Intro`, #3425), only the file is made absolute and the
|
||||
/// parameters are sent as given: as part of the path, `//` and `/./` in a destination name were
|
||||
/// folded, and on Windows `/` became `\`, `..` took the file name with it and a trailing dot went.
|
||||
pub fn absolute(paths: &[String]) -> Vec<String> {
|
||||
paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect()
|
||||
let made = |p: &str| std::path::absolute(p).map_or_else(|_| p.to_string(), |a| a.to_string_lossy().into_owned());
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| match pdfcraft_engine::open_parameters::split(p, |f| std::path::Path::new(f).is_file()) {
|
||||
// `file` is what comes before one of `p`'s `#`, so the rest starts at that `#`.
|
||||
Some((file, _)) => format!("{}{}", made(&file), p.get(file.len()..).unwrap_or_default()),
|
||||
None => made(p),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
|
||||
@@ -416,6 +427,26 @@ mod tests {
|
||||
assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]);
|
||||
}
|
||||
|
||||
/// #3425: the open parameters after a file reach the running app as they were given. Made
|
||||
/// absolute with the file, `a//b/./c` became `a/b/c` (`a\b\c` on Windows), and on Windows
|
||||
/// `x/../y` lost the file name and `Intro.` its dot.
|
||||
#[test]
|
||||
fn only_the_file_before_open_parameters_is_made_absolute() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-open-parameters", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (manual, notes) = (dir.join("manual.pdf"), dir.join("notes#2.pdf"));
|
||||
std::fs::write(&manual, b"%PDF-1.7").unwrap();
|
||||
std::fs::write(¬es, b"%PDF-1.7").unwrap();
|
||||
let made = |p: &std::path::Path| std::path::absolute(p).unwrap().to_string_lossy().into_owned();
|
||||
for parameters in ["#nameddest=a//b/./c", "#nameddest=x/../y", "#Intro.", "#page=2"] {
|
||||
let given = format!("{}{parameters}", manual.to_string_lossy());
|
||||
assert_eq!(absolute(&[given]), vec![format!("{}{parameters}", made(&manual))], "{parameters}");
|
||||
}
|
||||
// A file with `#` in its name is made absolute whole, as before.
|
||||
assert_eq!(absolute(&[notes.to_string_lossy().into_owned()]), vec![made(¬es)]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_windows_session_gets_its_own_socket_name() {
|
||||
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
|
||||
|
||||
@@ -49,7 +49,7 @@ fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) {
|
||||
#[test]
|
||||
fn vendor_provenance_window_stack_graph_is_resolved() {
|
||||
assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc");
|
||||
assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a");
|
||||
assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "725a39fca940f77149ffbb9c58eef405a16997bc");
|
||||
// Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows.
|
||||
let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md");
|
||||
for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] {
|
||||
|
||||
@@ -191,3 +191,6 @@ real_name = "Helen Kwok"
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.enriquemelzer-bit]
|
||||
sync_github_name = true
|
||||
+177
-101
@@ -1,9 +1,12 @@
|
||||
//! Create a valid, reviewable structure tree for PDFs whose painted content is text only.
|
||||
//! Create a valid, reviewable structure tree for untagged PDFs.
|
||||
//!
|
||||
//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph,
|
||||
//! which can then be retagged with the existing tag-property tool after human review.
|
||||
//! Each text object becomes a paragraph, which can then be retagged with the existing
|
||||
//! tag-property tool after human review. Painted paths, shadings and images outside text objects
|
||||
//! are wrapped in `/Artifact BMC … EMC` so the resulting page passes the tagged-content check.
|
||||
|
||||
use pdfcraft_content::{Op, parse, serialize_ops};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_content::{Op, Pieces, serialize_ops};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
const MAX_PAGES: usize = 1_000;
|
||||
@@ -41,25 +44,27 @@ struct Plan {
|
||||
page: ObjRef,
|
||||
ops: Vec<Op>,
|
||||
spans: Vec<(usize, usize)>,
|
||||
artifacts: HashMap<usize, usize>,
|
||||
}
|
||||
|
||||
fn unsupported(page: usize, reason: impl Into<String>) -> AutoTagError {
|
||||
AutoTagError::Unsupported { page: page + 1, reason: reason.into() }
|
||||
}
|
||||
|
||||
fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Vec<u8>, AutoTagError> {
|
||||
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) };
|
||||
fn content_pieces(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Pieces, AutoTagError> {
|
||||
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Pieces::join::<&[u8]>(&[])) };
|
||||
let resolved = doc.resolve(contents);
|
||||
let pieces: Vec<Object> = match &*resolved {
|
||||
let items: Vec<Object> = match &*resolved {
|
||||
Object::Array(items) => items.clone(),
|
||||
Object::Stream(_) => vec![contents.clone()],
|
||||
_ => return Err(unsupported(index, "contents are not a stream or array of streams")),
|
||||
};
|
||||
let mut all = Vec::new();
|
||||
for item in pieces {
|
||||
let mut pieces = Vec::with_capacity(items.len());
|
||||
let mut total = 0usize;
|
||||
for item in items {
|
||||
let value = doc.resolve(&item);
|
||||
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
|
||||
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
|
||||
let remaining = MAX_PAGE_BYTES.saturating_sub(total.saturating_add(1));
|
||||
if remaining == 0 {
|
||||
return Err(unsupported(index, "decoding the content takes more than 16 MiB"));
|
||||
}
|
||||
@@ -79,10 +84,18 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
|
||||
unsupported(index, format!("content cannot be decoded strictly: {why}"))
|
||||
}
|
||||
})?;
|
||||
all.extend_from_slice(&decoded);
|
||||
all.push(b' ');
|
||||
total = total.saturating_add(decoded.len()).saturating_add(1);
|
||||
pieces.push(decoded);
|
||||
}
|
||||
Ok(all)
|
||||
Ok(Pieces::join(&pieces))
|
||||
}
|
||||
|
||||
fn page_xobjects(doc: &Document, page: &Dict) -> Dict {
|
||||
page.get(b"Resources")
|
||||
.map(|r| doc.resolve(r))
|
||||
.and_then(|r| r.as_dict().and_then(|r| r.get(b"XObject").map(|x| doc.resolve(x))))
|
||||
.and_then(|x| x.as_dict().cloned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn harmless_outside(op: &[u8]) -> bool {
|
||||
@@ -110,9 +123,35 @@ fn harmless_outside(op: &[u8]) -> bool {
|
||||
| b"sc"
|
||||
| b"SCN"
|
||||
| b"scn"
|
||||
| b"m"
|
||||
| b"l"
|
||||
| b"c"
|
||||
| b"v"
|
||||
| b"y"
|
||||
| b"h"
|
||||
| b"re"
|
||||
| b"W"
|
||||
| b"W*"
|
||||
| b"n"
|
||||
)
|
||||
}
|
||||
|
||||
fn painted_outside(doc: &Document, xobjects: &Dict, op: &Op) -> bool {
|
||||
match op.op.as_slice() {
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" | b"BI" => true,
|
||||
b"Do" => op
|
||||
.name(0)
|
||||
.and_then(|n| xobjects.get(n))
|
||||
.map(|o| doc.resolve(o))
|
||||
.and_then(|o| match &*o {
|
||||
Object::Stream(s) => s.dict.name(b"Subtype").map(<[u8]>::to_vec),
|
||||
_ => None,
|
||||
})
|
||||
.is_some_and(|st| st == b"Image"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn harmless_inside(op: &[u8]) -> bool {
|
||||
matches!(
|
||||
op,
|
||||
@@ -142,17 +181,38 @@ fn harmless_inside(op: &[u8]) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged.
|
||||
fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagError> {
|
||||
let mut start = None;
|
||||
let mut has_text = false;
|
||||
let mut spans = Vec::new();
|
||||
for (at, op) in ops.iter().enumerate() {
|
||||
/// `totals` is the decoded bytes and operators of the pages planned so far, bounded by
|
||||
/// `MAX_TOTAL_BYTES` and `MAX_TOTAL_OPERATORS`.
|
||||
fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: &mut (usize, usize)) -> Result<Plan, AutoTagError> {
|
||||
if page.dict.contains(b"StructParents") {
|
||||
return Err(unsupported(index, "page already has a structure parent key"));
|
||||
}
|
||||
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
|
||||
Object::Array(items) => !items.is_empty(),
|
||||
_ => true,
|
||||
}) {
|
||||
return Err(unsupported(index, "annotations need separate structure tags"));
|
||||
}
|
||||
let pieces = content_pieces(doc, page, index)?;
|
||||
totals.0 = totals.0.saturating_add(pieces.data().len());
|
||||
let parsed = pieces.parse();
|
||||
if parsed.skipped > 0 || parsed.ambiguous_images > 0 || parsed.ops.len() > MAX_OPERATORS {
|
||||
return Err(unsupported(index, "content has malformed or too many operators"));
|
||||
}
|
||||
totals.1 = totals.1.saturating_add(parsed.ops.len());
|
||||
if totals.0 > MAX_TOTAL_BYTES || totals.1 > MAX_TOTAL_OPERATORS {
|
||||
return Err(unsupported(index, "the document has too much content to auto-tag in one edit"));
|
||||
}
|
||||
let xobjects = page_xobjects(doc, &page.dict);
|
||||
let (mut start, mut has_text, mut spans, mut artifacts) = (None, false, Vec::new(), HashMap::new());
|
||||
// Marked-content boundaries must enclose the whole path, not just its paint operator.
|
||||
let mut path = None;
|
||||
for (at, op) in parsed.ops.iter().enumerate() {
|
||||
if path.is_some() && matches!(op.op.as_slice(), b"BT" | b"Do" | b"BI" | b"sh") {
|
||||
return Err(unsupported(index, "painted content interrupts an unfinished path"));
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"BT" if start.is_none() => {
|
||||
start = Some(at);
|
||||
has_text = false;
|
||||
}
|
||||
b"BT" if start.is_none() => (start, has_text) = (Some(at), false),
|
||||
b"ET" => {
|
||||
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
|
||||
if has_text {
|
||||
@@ -164,14 +224,31 @@ fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagEr
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
|
||||
name if start.is_some() && harmless_inside(name) => {}
|
||||
name if start.is_none() && harmless_outside(name) => {}
|
||||
name if start.is_none() && harmless_outside(name) => match name {
|
||||
b"m" | b"re" => {
|
||||
path.get_or_insert(at);
|
||||
}
|
||||
b"n" => path = None,
|
||||
_ => {}
|
||||
},
|
||||
_ if start.is_none() && painted_outside(doc, &xobjects, op) => {
|
||||
let first = if matches!(op.op.as_slice(), b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s") {
|
||||
path.take().unwrap_or(at)
|
||||
} else {
|
||||
at
|
||||
};
|
||||
artifacts.insert(first, at);
|
||||
}
|
||||
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
|
||||
}
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(index, "unterminated BT text object"));
|
||||
}
|
||||
Ok(spans)
|
||||
if path.is_some() {
|
||||
return Err(unsupported(index, "unterminated path object"));
|
||||
}
|
||||
Ok(Plan { page: page.obj, ops: parsed.ops, spans, artifacts })
|
||||
}
|
||||
|
||||
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
@@ -190,30 +267,11 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
}
|
||||
let mut out = Vec::with_capacity(pages.len());
|
||||
let mut total = 0usize;
|
||||
let (mut total_bytes, mut total_ops) = (0usize, 0usize);
|
||||
let mut totals = (0usize, 0usize);
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
if page.dict.contains(b"StructParents") {
|
||||
return Err(unsupported(index, "page already has a structure parent key"));
|
||||
}
|
||||
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
|
||||
Object::Array(items) => !items.is_empty(),
|
||||
_ => true,
|
||||
}) {
|
||||
return Err(unsupported(index, "annotations need separate structure tags"));
|
||||
}
|
||||
let bytes = content_bytes(doc, page, index)?;
|
||||
total_bytes = total_bytes.saturating_add(bytes.len());
|
||||
let parsed = parse(&bytes);
|
||||
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
|
||||
return Err(unsupported(index, "content has malformed or too many operators"));
|
||||
}
|
||||
total_ops = total_ops.saturating_add(parsed.ops.len());
|
||||
if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS {
|
||||
return Err(unsupported(index, "the document has too much content to auto-tag in one edit"));
|
||||
}
|
||||
let spans = text_spans(&parsed.ops, index)?;
|
||||
total = total.saturating_add(spans.len());
|
||||
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
|
||||
let planned = plan_page(doc, page, index, &mut totals)?;
|
||||
total = total.saturating_add(planned.spans.len());
|
||||
out.push(planned);
|
||||
}
|
||||
if total == 0 {
|
||||
return Err(AutoTagError::NoText);
|
||||
@@ -221,62 +279,36 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree,
|
||||
/// parent tree and marked-content IDs are written together. Mixed content is refused before any
|
||||
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
|
||||
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
|
||||
let plans = plan(doc)?;
|
||||
let mut next = doc.clone();
|
||||
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
|
||||
let root = next.add(Object::Null);
|
||||
let document = next.add(Object::Null);
|
||||
let mut children = Vec::new();
|
||||
let mut numbers = Vec::new();
|
||||
let mut tagged_pages = 0usize;
|
||||
let mut paragraphs = 0usize;
|
||||
for page in plans {
|
||||
if page.spans.is_empty() {
|
||||
continue;
|
||||
fn mark_ops(ops: Vec<Op>, starts: &HashMap<usize, usize>, ends: &HashSet<usize>, artifacts: &HashMap<usize, usize>) -> Result<Vec<Op>, AutoTagError> {
|
||||
let artifact_ends: HashSet<usize> = artifacts.values().copied().collect();
|
||||
let extra = starts.len().saturating_add(artifacts.len()).saturating_mul(2);
|
||||
let mut marked = Vec::with_capacity(ops.len().saturating_add(extra));
|
||||
for (at, op) in ops.into_iter().enumerate() {
|
||||
let is_artifact = artifacts.contains_key(&at);
|
||||
if let Some(mcid) = starts.get(&at) {
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
|
||||
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
|
||||
} else if is_artifact {
|
||||
marked.push(Op::new("BMC", vec![Object::name("Artifact")]));
|
||||
}
|
||||
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
|
||||
let mut parents = Vec::with_capacity(page.spans.len());
|
||||
let mut starts = std::collections::HashMap::new();
|
||||
let mut ends = std::collections::HashSet::new();
|
||||
for (mcid, (first, last)) in page.spans.iter().enumerate() {
|
||||
let mut element = Dict::new();
|
||||
element.set(b"Type".to_vec(), Object::name("StructElem"));
|
||||
element.set(b"S".to_vec(), Object::name("P"));
|
||||
element.set(b"P".to_vec(), Object::Ref(document));
|
||||
element.set(b"Pg".to_vec(), Object::Ref(page.page));
|
||||
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
|
||||
let reference = next.add(element);
|
||||
parents.push(Object::Ref(reference));
|
||||
children.push(Object::Ref(reference));
|
||||
starts.insert(*first, mcid);
|
||||
ends.insert(*last);
|
||||
paragraphs = paragraphs.saturating_add(1);
|
||||
marked.push(op);
|
||||
if ends.contains(&at) || artifact_ends.contains(&at) {
|
||||
marked.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2)));
|
||||
for (at, op) in page.ops.into_iter().enumerate() {
|
||||
if let Some(mcid) = starts.get(&at) {
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
|
||||
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
|
||||
}
|
||||
marked.push(op);
|
||||
if ends.contains(&at) {
|
||||
marked.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
}
|
||||
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
|
||||
next.update_dict(page.page, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Ref(stream));
|
||||
d.set(b"StructParents".to_vec(), Object::Int(key));
|
||||
})?;
|
||||
numbers.push(Object::Int(key));
|
||||
numbers.push(Object::Array(parents));
|
||||
tagged_pages += 1;
|
||||
}
|
||||
Ok(marked)
|
||||
}
|
||||
|
||||
fn write_structure(
|
||||
next: &mut Document,
|
||||
catalog: ObjRef,
|
||||
root: ObjRef,
|
||||
document: ObjRef,
|
||||
children: Vec<Object>,
|
||||
numbers: Vec<Object>,
|
||||
tagged_pages: usize,
|
||||
) -> Result<(), AutoTagError> {
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(numbers));
|
||||
let parent_tree = next.add(tree);
|
||||
@@ -299,6 +331,50 @@ pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
|
||||
d.set(b"StructTreeRoot".to_vec(), Object::Ref(root));
|
||||
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tag every text object in an untagged PDF as a paragraph and mark painted paths, shadings and
|
||||
/// images outside text objects as artifacts. The full structure tree, parent tree and
|
||||
/// marked-content IDs are written together. Unsupported operators are refused before any
|
||||
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
|
||||
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
|
||||
let plans = plan(doc)?;
|
||||
let mut next = doc.clone();
|
||||
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
|
||||
let (root, document) = (next.add(Object::Null), next.add(Object::Null));
|
||||
let (mut children, mut numbers, mut tagged_pages, mut paragraphs) = (Vec::new(), Vec::new(), 0usize, 0usize);
|
||||
for page in plans {
|
||||
if page.spans.is_empty() && page.artifacts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (mut starts, mut ends) = (HashMap::new(), HashSet::new());
|
||||
if !page.spans.is_empty() {
|
||||
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
|
||||
let mut parents = Vec::with_capacity(page.spans.len());
|
||||
for (mcid, (first, last)) in page.spans.iter().enumerate() {
|
||||
let mut element = Dict::new();
|
||||
element.set(b"Type".to_vec(), Object::name("StructElem"));
|
||||
element.set(b"S".to_vec(), Object::name("P"));
|
||||
element.set(b"P".to_vec(), Object::Ref(document));
|
||||
element.set(b"Pg".to_vec(), Object::Ref(page.page));
|
||||
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
|
||||
let reference = next.add(element);
|
||||
parents.push(Object::Ref(reference));
|
||||
children.push(Object::Ref(reference));
|
||||
starts.insert(*first, mcid);
|
||||
ends.insert(*last);
|
||||
paragraphs = paragraphs.saturating_add(1);
|
||||
}
|
||||
next.update_dict(page.page, |d| d.set(b"StructParents".to_vec(), Object::Int(key)))?;
|
||||
numbers.extend([Object::Int(key), Object::Array(parents)]);
|
||||
tagged_pages += 1;
|
||||
}
|
||||
let marked = mark_ops(page.ops, &starts, &ends, &page.artifacts)?;
|
||||
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
|
||||
next.update_dict(page.page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream)))?;
|
||||
}
|
||||
write_structure(&mut next, catalog, root, document, children, numbers, tagged_pages)?;
|
||||
*doc = next;
|
||||
Ok(AutoTagResult { pages: tagged_pages, paragraphs })
|
||||
}
|
||||
@@ -57,7 +57,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
|
||||
let mut widgets = Vec::new();
|
||||
// A widget listed in more than one page's /Annots is tagged once (one key, one /Form).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// A shared indirect /Annots array is scanned for the first page that lists it: its widgets
|
||||
// are tagged once anyway, so scanning it again for every page only repeats the work.
|
||||
let mut arrays = std::collections::HashSet::new();
|
||||
for page in &pages {
|
||||
if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref)
|
||||
&& !arrays.insert(array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default();
|
||||
for annot in annots {
|
||||
let Some(reference) = annot.as_ref() else { continue };
|
||||
|
||||
@@ -262,11 +262,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
|
||||
|
||||
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
|
||||
let mut out = Vec::new();
|
||||
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
|
||||
// The annotation objects are shared, never deep-copied: a reference repeated across a page's
|
||||
// Annots array (or its pages) must not copy the dictionary each time.
|
||||
let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc<Object>)> {
|
||||
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d))))
|
||||
.filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup"))
|
||||
.filter_map(|x| x.as_ref().map(|r| (r, doc.get(r))))
|
||||
.filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup")))
|
||||
.collect()
|
||||
};
|
||||
match rule {
|
||||
@@ -284,7 +286,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
||||
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
|
||||
for &p in list {
|
||||
let Some(page) = pages.get(p) else { continue };
|
||||
for (_, a) in annots(page) {
|
||||
for (_, obj) in annots(page) {
|
||||
let Some(a) = obj.as_dict() else { continue };
|
||||
let st = a.name(b"Subtype").unwrap_or(b"");
|
||||
let applies = match rule {
|
||||
Rule::TaggedFormFields => st == b"Widget",
|
||||
@@ -299,11 +302,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
||||
let pattern = if st == b"Widget" {
|
||||
"Form field on page {p} isn't tagged"
|
||||
} else {
|
||||
values.push(("{}", subtype_name(&a)));
|
||||
values.push(("{}", subtype_name(a)));
|
||||
"{} annotation on page {p} isn't tagged"
|
||||
};
|
||||
let mut finding = Finding::worded(Some(p), pattern, values);
|
||||
finding.bbox = annotation_rect(doc, &a);
|
||||
finding.bbox = annotation_rect(doc, a);
|
||||
out.push(finding);
|
||||
}
|
||||
}
|
||||
@@ -345,7 +348,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
||||
let mut seen = HashSet::new();
|
||||
for &p in list {
|
||||
let Some(page) = pages.get(p) else { continue };
|
||||
for (r, a) in annots(page) {
|
||||
for (r, obj) in annots(page) {
|
||||
let Some(a) = obj.as_dict() else { continue };
|
||||
if a.name(b"Subtype") != Some(b"Widget") {
|
||||
continue;
|
||||
}
|
||||
@@ -362,7 +366,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
||||
"Field \"{}\" on page {p} has no description (tooltip)",
|
||||
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
|
||||
);
|
||||
finding.bbox = annotation_rect(doc, &a);
|
||||
finding.bbox = annotation_rect(doc, a);
|
||||
out.push(finding);
|
||||
}
|
||||
}
|
||||
|
||||
+139
-2
@@ -142,17 +142,120 @@ fn autotag_text_says_why_it_cannot_read_a_page() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
|
||||
fn autotag_text_marks_vector_graphics_and_images_as_artifacts() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>".into(),
|
||||
stream("0 0 200 200 m 200 0 l 200 200 l 0 200 l h f 10 10 50 50 re B BT /F1 12 Tf 20 100 Td (Hello) Tj ET q 20 0 0 20 80 80 cm /Im1 Do Q"),
|
||||
FONT.into(),
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 8 0 R >>".into(),
|
||||
stream("0 0 100 100 re S"),
|
||||
],
|
||||
"",
|
||||
);
|
||||
let result = autotag_text(&mut doc).unwrap();
|
||||
assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1 });
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
|
||||
assert_eq!(status(&report, Rule::TaggedContent), Status::Passed);
|
||||
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(bytes)).unwrap();
|
||||
let reopened_report = check(&reopened, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(status(&reopened_report, Rule::TaggedPdf), Status::Passed);
|
||||
assert_eq!(status(&reopened_report, Rule::TaggedContent), Status::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_artifacts_wrap_complete_paths_and_leave_unpainted_clips_outside() {
|
||||
let content = "0 0 m 10 0 l 10 10 l h f 0 0 20 20 re W B 0 0 50 50 re W* n /Shade sh BT /F1 12 Tf (Text) Tj ET 3 3 5 5 re S";
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"),
|
||||
stream(content),
|
||||
FONT.into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
autotag_text(&mut doc).unwrap();
|
||||
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
let content = doc.resolve(page.dict.get(b"Contents").unwrap());
|
||||
let Object::Stream(content) = &*content else { panic!("page stream") };
|
||||
let ops = pdfcraft_content::parse(&content.decoded_strict().unwrap()).ops;
|
||||
let names: Vec<&[u8]> = ops.iter().map(|op| op.op.as_slice()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
b"BMC".as_slice(),
|
||||
b"m",
|
||||
b"l",
|
||||
b"l",
|
||||
b"h",
|
||||
b"f",
|
||||
b"EMC",
|
||||
b"BMC",
|
||||
b"re",
|
||||
b"W",
|
||||
b"B",
|
||||
b"EMC",
|
||||
b"re",
|
||||
b"W*",
|
||||
b"n",
|
||||
b"BMC",
|
||||
b"sh",
|
||||
b"EMC",
|
||||
b"BDC",
|
||||
b"BT",
|
||||
b"Tf",
|
||||
b"Tj",
|
||||
b"ET",
|
||||
b"EMC",
|
||||
b"BMC",
|
||||
b"re",
|
||||
b"S",
|
||||
b"EMC",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_rejects_text_interleaved_with_an_unfinished_path_atomically() {
|
||||
for tail in ["10 10 l S", "n"] {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream(&format!("0 0 m BT /F1 12 Tf (Text) Tj ET {tail}")),
|
||||
FONT.into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
let before = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
|
||||
assert_eq!(pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_text_refuses_form_xobjects_and_does_not_modify_the_document() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> >>".into(),
|
||||
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET /Fm1 Do"),
|
||||
FONT.into(),
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 12 >>\nstream\n0 0 10 10 re f\nendstream".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
|
||||
assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
|
||||
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
|
||||
@@ -1780,3 +1883,37 @@ fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowan
|
||||
let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") };
|
||||
assert!(reason.contains("too much content"), "{reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_once() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(),
|
||||
"[5 0 R 5 0 R]".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() {
|
||||
let doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 4 0 R 5 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false);
|
||||
assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported");
|
||||
}
|
||||
@@ -27,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
|
||||
comment another program drew with a font that has those characters, whose appearance is kept
|
||||
rather than redrawn with `?` (new text for it is taken).
|
||||
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
|
||||
does), change text, move, resize, restyle, delete (with pop-up and replies).
|
||||
does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close
|
||||
or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment
|
||||
that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up.
|
||||
|
||||
## API sketch
|
||||
|
||||
|
||||
+115
-31
@@ -8,19 +8,25 @@
|
||||
//! itself; for the other values, callers that hold the document follow references first
|
||||
//! (`draw::with_direct_values`).
|
||||
//!
|
||||
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
|
||||
//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3
|
||||
//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses
|
||||
//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances.
|
||||
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use a standard text
|
||||
//! font with WinAnsi encoding: Helvetica, or the face their `/DA` or `/DS` asks for
|
||||
//! ([`crate::text_style`]), and draw Thai from the craft-fonts Thai face as Type 3 glyphs
|
||||
//! (`pdfcraft_fonts::shaped_text`, the writer Add text uses); lines break at that face's
|
||||
//! standard-14 widths and the Thai face's shaped advances (no other font program is bundled).
|
||||
|
||||
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::shaped_text;
|
||||
use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with};
|
||||
use pdfcraft_fonts::{first_undrawable, literal, win_ansi, wrap_with};
|
||||
|
||||
/// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai
|
||||
/// face that text boxes draw it with.
|
||||
pub fn text_width(text: &str, size: f64) -> f64 {
|
||||
shaped_text::thai_width(text, size, helvetica_width)
|
||||
text_width_in(pdfcraft_fonts::Std14::Helvetica, text, size)
|
||||
}
|
||||
|
||||
/// [`text_width`] in the standard `face` a text box draws its other text in.
|
||||
pub fn text_width_in(face: pdfcraft_fonts::Std14, text: &str, size: f64) -> f64 {
|
||||
shaped_text::thai_width(text, size, |s, size| face.text_width(s, size))
|
||||
}
|
||||
|
||||
/// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter.
|
||||
@@ -410,13 +416,17 @@ pub fn da_border(d: &Dict) -> Option<Rgb> {
|
||||
col
|
||||
}
|
||||
|
||||
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt.
|
||||
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt: a `Tf` without a
|
||||
/// usable size (missing, zero, negative or not a number) keeps 12 pt, and sizes past 400 pt are
|
||||
/// capped. A text box has no automatic size, so a zero size, which means auto-size in a form
|
||||
/// field's `/DA` (ISO 32000-2 §12.7.4.3), is the default here. An operand that isn't a finite
|
||||
/// number (`NaN`, `inf`, which Rust's parser would otherwise accept) is ignored with its operator.
|
||||
pub fn parse_da(d: &Dict) -> (Rgb, f64) {
|
||||
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default();
|
||||
let toks: Vec<&str> = da.split_whitespace().collect();
|
||||
let mut col = [0.0; 3];
|
||||
let mut size = 12.0;
|
||||
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok());
|
||||
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
match *t {
|
||||
"rg" if i >= 3 => {
|
||||
@@ -544,7 +554,9 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
|
||||
b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)),
|
||||
b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?),
|
||||
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
|
||||
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
|
||||
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") => {
|
||||
("the stamp's \"By\" line", text(b"PCByLine")?)
|
||||
}
|
||||
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
|
||||
b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?),
|
||||
_ => return None,
|
||||
@@ -554,18 +566,19 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
|
||||
|
||||
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
|
||||
pub(crate) fn helvetica_resource() -> Object {
|
||||
Object::Dict(helvetica_fonts())
|
||||
Object::Dict(font_resources(pdfcraft_fonts::Std14::Helvetica))
|
||||
}
|
||||
|
||||
/// [`helvetica_resource`]'s dictionary.
|
||||
fn helvetica_fonts() -> Dict {
|
||||
/// `/Font` resources naming one standard face with WinAnsiEncoding, under the resource name
|
||||
/// [`crate::text_style::resource_name`] gives it (`/Helv` for Helvetica).
|
||||
fn font_resources(face: pdfcraft_fonts::Std14) -> Dict {
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name(face.base_font()));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"Helv".to_vec(), Object::Dict(font));
|
||||
fonts.set(crate::text_style::resource_name(face).as_bytes().to_vec(), Object::Dict(font));
|
||||
fonts
|
||||
}
|
||||
|
||||
@@ -661,7 +674,8 @@ pub(crate) fn build_on_page(doc: &Document, d: &Dict, page: Option<[f64; 4]>) ->
|
||||
b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing),
|
||||
b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing),
|
||||
b"Stamp" => return draw_stamp(&context, drawing),
|
||||
b"FreeText" => return draw_free_text(&context, drawing),
|
||||
// The face a text box's `/DA` font names in the form's `/DR`, when it names one.
|
||||
b"FreeText" => return draw_free_text(&context, drawing, crate::text_style::dr_face(doc, d)),
|
||||
_ => {}
|
||||
}
|
||||
let mut drawing = drawing;
|
||||
@@ -971,11 +985,31 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing)
|
||||
}
|
||||
|
||||
fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
|
||||
let d = context.dict;
|
||||
let stroke = context.stroke;
|
||||
let c = &mut drawing.content;
|
||||
// While marked: the outline of each area (the fill comes when applied).
|
||||
let q = quads(context)?;
|
||||
let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
|
||||
// ISO 32000-2 gives a mark no outline colour (§12.5.6.23, Table 195). An array `/OC` of
|
||||
// three numbers is the one some writers give it, and is drawn (the observation is in
|
||||
// `parity/acrobat-features.toml`); `/OC` as ISO 32000-2 defines it (Table 166) is an
|
||||
// optional-content dictionary, which this leaves alone.
|
||||
let outline = nums(d, b"OC").and_then(|c| match c.as_slice() {
|
||||
[r, g, b] if c.iter().all(|x| x.is_finite()) => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
|
||||
_ => None,
|
||||
});
|
||||
// Quadrilaterals are outlined in the `/OC` colour, else in `/C` as they always were.
|
||||
let (q, col) = match d.get(b"QuadPoints") {
|
||||
Some(_) => (quads(context)?, outline.or(stroke)),
|
||||
// Without `/QuadPoints`, `/Rect` is the area (Table 195): outlined just inside its
|
||||
// edges when the mark names an outline colour, else not drawn while marked.
|
||||
None => {
|
||||
let outline = outline?;
|
||||
let rect = context.rect;
|
||||
let [x0, y0, x1, y1] = [rect[0] + 0.5, rect[1] + 0.5, rect[2] - 0.5, rect[3] - 0.5];
|
||||
(vec![[x0, y1, x1, y1, x0, y0, x1, y0]], Some(outline))
|
||||
}
|
||||
};
|
||||
let col = col.unwrap_or([0.89, 0.13, 0.13]);
|
||||
c.push_str(&format!("{}1 w\n", rg_stroke(col)));
|
||||
for quad in q.iter() {
|
||||
c.push_str(&format!(
|
||||
@@ -1230,7 +1264,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
return Some(form(rect, c.as_bytes(), res));
|
||||
}
|
||||
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
|
||||
// the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets
|
||||
// the rectangle. An image signature or a stamp with a line added to a turned page
|
||||
// (`/PCPictureRotate`) gets
|
||||
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
|
||||
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
|
||||
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
|
||||
@@ -1239,14 +1274,61 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
[w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
|
||||
_ => None,
|
||||
});
|
||||
let place = picture_place(rect, image, size)?;
|
||||
// A text line (#1741) takes the bottom of the rectangle, and the picture keeps its
|
||||
// shape, centred above it. Without one the picture fills the rectangle.
|
||||
let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty());
|
||||
let turn = picture_turn(d);
|
||||
// A lined stamp on a quarter-turned page is laid out in its displayed box (the
|
||||
// rectangle with its sides swapped) and turned into `/Rect` by the `/Matrix` below.
|
||||
// Laid out in user space, the viewer's fit of the turned box to `/Rect` would scale
|
||||
// the two displayed axes differently and squash the picture and its line.
|
||||
let bbox = if line.is_some() && matches!(turn, 90 | 270) { [0.0, 0.0, rect[3] - rect[1], rect[2] - rect[0]] } else { rect };
|
||||
let [x0, y0, x1, y1] = bbox;
|
||||
let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 };
|
||||
let (w, h) = (x1 - x0, y1 - y0 - band);
|
||||
let place = match (&line, size) {
|
||||
(Some(_), Some((sw, sh))) => {
|
||||
let k = (w / sw).min(h / sh).max(0.0);
|
||||
let (pw, ph) = (sw * k, sh * k);
|
||||
let (px, py) = (x0 + (w - pw) / 2.0, y0 + band + (h - ph) / 2.0);
|
||||
if image {
|
||||
format!("{} 0 0 {} {} {} cm", n(pw), n(ph), n(px), n(py))
|
||||
} else {
|
||||
format!("{} 0 0 {} {} {} cm", n(k), n(k), n(px), n(py))
|
||||
}
|
||||
}
|
||||
// Without a line the band is empty and this is the rectangle itself.
|
||||
_ => picture_place([x0, y0 + band, x1, y1], image, size)?,
|
||||
};
|
||||
c.push_str(&format!("q {place} /Pic Do Q\n"));
|
||||
// Bytes from here: the line is WinAnsi, not UTF-8.
|
||||
let mut content = std::mem::take(c).into_bytes();
|
||||
if let Some(l) = &line {
|
||||
// Centred in the band, at its usual size unless the stamp was made narrower.
|
||||
let mut size = band * crate::CUSTOM_LINE_TEXT;
|
||||
let tw = text_width(l, size);
|
||||
if tw > w - 4.0 && tw > 0.0 {
|
||||
size *= ((w - 4.0) / tw).max(0.0);
|
||||
}
|
||||
let tw = text_width(l, size);
|
||||
let ty = y0 + (band - size * 0.72) / 2.0;
|
||||
content.extend(format!("BT\n0.2 0.2 0.2 rg\n/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(size), n(x0 + (w - tw) / 2.0), n(ty)).bytes());
|
||||
content.extend(literal(&win_ansi(l)));
|
||||
content.extend_from_slice(b" Tj\nET\n");
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"Helv".to_vec(), Object::Dict(f));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
}
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Pic".to_vec(), Object::Ref(pic));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
let mut stream = form(rect, c.as_bytes(), res);
|
||||
let turn = picture_turn(d);
|
||||
if image && turn != 0 {
|
||||
let mut stream = form(bbox, &content, res);
|
||||
if (image || line.is_some()) && turn != 0 {
|
||||
// The page's view matrix without its offset: display axes in user space.
|
||||
let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
|
||||
let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0];
|
||||
@@ -1359,7 +1441,7 @@ fn shaped_border(
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
|
||||
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing, dr_face: Option<pdfcraft_fonts::Std14>) -> Option<Stream> {
|
||||
let d = context.dict;
|
||||
let parts = context.parts;
|
||||
let rect = context.rect;
|
||||
@@ -1367,11 +1449,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
let mut c = std::mem::take(&mut drawing.content);
|
||||
let mut res = std::mem::take(&mut drawing.resources);
|
||||
let _tail = std::mem::take(&mut drawing.tail);
|
||||
let (text_color, size) = parse_da(d);
|
||||
let crate::text_style::TextStyle { face, size, color: text_color } = crate::text_style::text_style(d, dr_face);
|
||||
// The box's border and a callout's leader line: their own colour, else the text's.
|
||||
let line_color = da_border(d).unwrap_or(text_color);
|
||||
let bg = stroke;
|
||||
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
|
||||
// Without `/BS` or `/Border`, the `/Border` default [0 0 1]: a 1-pt border (Table 166).
|
||||
let bw = border_width(d);
|
||||
// A callout: the leader line (arrowhead at its first point), and the text box inside
|
||||
// `/Rect` by `/RD`.
|
||||
// Text added to a turned page is drawn upright in its displayed box, which the form
|
||||
@@ -1469,37 +1552,38 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
c.push_str("/GS1 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
|
||||
"{} {} {} {} re W n\nBT\n/{} {} Tf\n{}",
|
||||
n(rect[0]),
|
||||
n(rect[1]),
|
||||
n(rect[2] - rect[0]),
|
||||
n(rect[3] - rect[1]),
|
||||
crate::text_style::resource_name(face),
|
||||
n(size),
|
||||
rg(text_color)
|
||||
));
|
||||
let mut out = c.into_bytes();
|
||||
let lines = wrap(&text, size, width);
|
||||
let lines = pdfcraft_fonts::wrap_with(&text, size, width, |s, size| text_width_in(face, s, size));
|
||||
let mut y = rect[3] - pad - size * reach(&lines)[0];
|
||||
if round {
|
||||
y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0);
|
||||
}
|
||||
// Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose
|
||||
// streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as
|
||||
// `?`, which `undrawable_text` reports), in Helvetica.
|
||||
let mut fonts = helvetica_fonts();
|
||||
// `?`, which `undrawable_text` reports), in the box's standard face.
|
||||
let mut fonts = font_resources(face);
|
||||
let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts);
|
||||
let mut current = None;
|
||||
for pieces in &pieces {
|
||||
if y < rect[1] - size {
|
||||
break;
|
||||
}
|
||||
let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width);
|
||||
let lw = shaped_text::pieces_width(pieces, size, 0.0, |s, size| face.text_width(s, size));
|
||||
let x = match q {
|
||||
1 => rect[0] + pad + (width - lw) / 2.0,
|
||||
2 => rect[2] - pad - lw,
|
||||
_ => rect[0] + pad,
|
||||
};
|
||||
shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current);
|
||||
shaped.show_line(&mut out, pieces, x, y, size, crate::text_style::resource_name(face), win_ansi, |s| face.text_width(s, size), &mut current);
|
||||
y -= size * 1.2;
|
||||
}
|
||||
out.extend_from_slice(b"ET\n");
|
||||
|
||||
+129
-12
@@ -1,5 +1,5 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::lookup::{annot_dict, annot_ref, annots, change_annots, fill_sign_shape, page_ref};
|
||||
use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box};
|
||||
use super::types::{
|
||||
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
||||
@@ -204,14 +204,110 @@ fn subject(shape: &Shape) -> &'static str {
|
||||
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
|
||||
let [x0, y0, x1, y1] = normalize(rect);
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
if knee[0] < x0 {
|
||||
[x0, cy]
|
||||
} else if knee[0] > x1 {
|
||||
[x1, cy]
|
||||
} else if knee[1] > y1 {
|
||||
[cx, y1]
|
||||
match callout_side(rect, knee) {
|
||||
Side::Left => [x0, cy],
|
||||
Side::Right => [x1, cy],
|
||||
Side::Top => [cx, y1],
|
||||
Side::Bottom => [cx, y0],
|
||||
}
|
||||
}
|
||||
|
||||
/// A side of a callout's text box.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Whether a knee line on this side is horizontal (it meets the left or right side). Acrobat
|
||||
/// lets a knee line be resized along its own direction only (Acrobat DC Help, 11/28/2019,
|
||||
/// p. 357).
|
||||
pub fn horizontal(self) -> bool {
|
||||
matches!(self, Side::Left | Side::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// The side of the text box `rect` that faces `knee`: the one [`callout_attach`] attaches to.
|
||||
///
|
||||
/// Every position has one side (review on #4094):
|
||||
/// - left or right of the box (`x < x0`, `x > x1`): that side, whatever `y` is, so a knee off a
|
||||
/// corner diagonally, or on the line of the top or bottom edge past the box, is beside it;
|
||||
/// - otherwise above or below it (`y > y1`, `y < y0`): that side, so a knee on the line of the
|
||||
/// left or right edge past the box is above or below it;
|
||||
/// - on the box's outline, or within `CALLOUT_SLACK` (0.01 pt) inside it, since the page hands
|
||||
/// positions back as `f32` values: the side whose edge it lies on, the left or right edge
|
||||
/// first, so a corner takes the left or right side as a knee beside the box does. The line
|
||||
/// stays out of the box (it was sent across the box to the bottom side);
|
||||
/// - inside the box, or with a coordinate that isn't a number: the bottom side.
|
||||
pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side {
|
||||
let [x0, y0, x1, y1] = normalize(rect);
|
||||
let [x, y] = knee;
|
||||
// Before any comparison: one with NaN is false, so the other coordinate alone would decide.
|
||||
if x.is_nan() || y.is_nan() {
|
||||
return Side::Bottom;
|
||||
}
|
||||
if x < x0 {
|
||||
Side::Left
|
||||
} else if x > x1 {
|
||||
Side::Right
|
||||
} else if y > y1 {
|
||||
Side::Top
|
||||
} else if y < y0 {
|
||||
Side::Bottom
|
||||
} else if x - x0 < CALLOUT_SLACK {
|
||||
Side::Left
|
||||
} else if x1 - x < CALLOUT_SLACK {
|
||||
Side::Right
|
||||
} else if y1 - y < CALLOUT_SLACK {
|
||||
Side::Top
|
||||
} else {
|
||||
[cx, y0]
|
||||
Side::Bottom
|
||||
}
|
||||
}
|
||||
|
||||
/// A callout's knee line when Acrobat lays it out: 12 pt, out from the middle of the side. Seen in
|
||||
/// Acrobat Pro DC, a 2021 Continuous release: a new callout's knee line, and one laid out again
|
||||
/// after its knee was dragged into the box or the side it meets was moved, are all 12 pt, and the
|
||||
/// knee can't come nearer the box than that.
|
||||
pub const CALLOUT_KNEE_LINE: f64 = 12.0;
|
||||
|
||||
/// Where a knee goes on `side` of the text box `rect`: [`CALLOUT_KNEE_LINE`] out from the middle
|
||||
/// of that side, so the knee line is level (left, right) or upright (top, bottom).
|
||||
pub fn callout_knee_on(rect: [f64; 4], side: Side) -> [f64; 2] {
|
||||
let [x0, y0, x1, y1] = normalize(rect);
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
match side {
|
||||
Side::Left => [x0 - CALLOUT_KNEE_LINE, cy],
|
||||
Side::Right => [x1 + CALLOUT_KNEE_LINE, cy],
|
||||
Side::Top => [cx, y1 + CALLOUT_KNEE_LINE],
|
||||
Side::Bottom => [cx, y0 - CALLOUT_KNEE_LINE],
|
||||
}
|
||||
}
|
||||
|
||||
/// A new callout's knee: on the side of its text box `rect` that faces the arrow `point`
|
||||
/// ([`callout_knee_on`]).
|
||||
pub fn callout_knee(rect: [f64; 4], point: [f64; 2]) -> [f64; 2] {
|
||||
callout_knee_on(rect, callout_side(rect, point))
|
||||
}
|
||||
|
||||
/// How far apart, in points, two positions of a callout's box or line may be and still count as the
|
||||
/// same. The page hands a box back as `f32` values, which moves a coordinate such as 323.05 by a few
|
||||
/// hundred-thousandths of a point (review on #4094).
|
||||
pub(crate) const CALLOUT_SLACK: f64 = 0.01;
|
||||
|
||||
/// Whether `end` lies on `side` of the text box `rect`: the leader line can stay where it meets it.
|
||||
pub(crate) fn on_side(rect: [f64; 4], side: Side, end: [f64; 2]) -> bool {
|
||||
let [x0, y0, x1, y1] = normalize(rect);
|
||||
let near = |a: f64, b: f64| (a - b).abs() < CALLOUT_SLACK;
|
||||
let within = |v: f64, lo: f64, hi: f64| v > lo - CALLOUT_SLACK && v < hi + CALLOUT_SLACK;
|
||||
match side {
|
||||
Side::Left => near(end[0], x0) && within(end[1], y0, y1),
|
||||
Side::Right => near(end[0], x1) && within(end[1], y0, y1),
|
||||
Side::Top => near(end[1], y1) && within(end[0], x0, x1),
|
||||
Side::Bottom => near(end[1], y0) && within(end[0], x0, x1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,15 +392,20 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::<Vec<_>>())).collect();
|
||||
d.set(b"PCOutline".to_vec(), Object::Array(list));
|
||||
}
|
||||
Shape::CustomStamp { name, picture, image, size, .. } => {
|
||||
Shape::CustomStamp { name, picture, image, size, line, .. } => {
|
||||
let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
|
||||
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
|
||||
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
|
||||
d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1]));
|
||||
let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty());
|
||||
if let Some(l) = lined {
|
||||
d.set(b"PCByLine".to_vec(), PdfString::text(l));
|
||||
}
|
||||
// An image signature's rectangle is in user space; on a turned page its appearance is
|
||||
// turned back (see `appearance::build`), so it reads upright as displayed.
|
||||
if *image && matches!(clean.as_str(), "Signature" | "Initials") {
|
||||
// turned back (see `appearance::build`), so it reads upright as displayed. A stamp
|
||||
// with a line is laid out in the displayed box, so it needs the turn too.
|
||||
if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) {
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if turn != 0 {
|
||||
d.set(b"PCPictureRotate".to_vec(), Object::Int(turn));
|
||||
@@ -434,7 +535,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let index = list.len();
|
||||
list.push(Object::Ref(r));
|
||||
add_popup(doc, r, popup, &mut list)?;
|
||||
set_annots(doc, page, list)?;
|
||||
change_annots(doc, page, list)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
@@ -500,6 +601,22 @@ fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The share of a custom stamp's height its text line takes (the rest is the picture).
|
||||
pub const CUSTOM_LINE_BAND: f64 = 0.22;
|
||||
|
||||
/// A custom stamp line's text height as a share of its band.
|
||||
pub const CUSTOM_LINE_TEXT: f64 = 0.62;
|
||||
|
||||
/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`,
|
||||
/// the band under the picture is added, and the stamp is as wide as the line at its usual size
|
||||
/// when that is wider than the picture.
|
||||
pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) {
|
||||
let Some(line) = line else { return picture };
|
||||
let h = picture.1 / (1.0 - CUSTOM_LINE_BAND);
|
||||
let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0;
|
||||
(picture.0.max(text), h)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
+102
-10
@@ -1,7 +1,8 @@
|
||||
use super::create::finite;
|
||||
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::modify::{callout_line, callout_text_box, fillable, touch, unlocked};
|
||||
use super::text_style;
|
||||
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
@@ -23,7 +24,8 @@ pub struct Summary {
|
||||
pub in_reply_to: Option<String>,
|
||||
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
|
||||
pub parent: Option<(usize, usize)>,
|
||||
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
|
||||
/// `/RT /Group`: with an `/IRT` reference, a subordinate of the group whose primary that is
|
||||
/// ([`is_group_subordinate`]), not a reply to it.
|
||||
pub grouped: bool,
|
||||
pub rect: [f32; 4],
|
||||
pub color: Option<[f32; 3]>,
|
||||
@@ -35,9 +37,66 @@ pub struct Summary {
|
||||
pub fill_sign: bool,
|
||||
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
|
||||
pub no_rotate: bool,
|
||||
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
|
||||
/// (`/Vertices`), in user space; empty for other comments.
|
||||
/// A line's two ends (`/L`), the corners of connected lines, a polygon or a cloud
|
||||
/// (`/Vertices`), or a callout's arrow point and knee (`/CL` without its end, which follows
|
||||
/// the text box), in user space; empty for other comments.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
|
||||
pub text_box: Option<[f32; 4]>,
|
||||
/// The comment's pop-up window (`/Popup`), when it has one.
|
||||
pub popup: Option<PopupInfo>,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
/// A subordinate of a group (`/RT /Group` with an `/IRT` to a listed comment, ISO 32000-2
|
||||
/// §12.5.6.2): part of its primary's comment, not a reply. As
|
||||
/// `pdfcraft_render::Annotation::is_group_member`, which also drops a looping `/IRT`.
|
||||
pub fn is_group_member(&self) -> bool {
|
||||
self.grouped && self.parent.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it
|
||||
/// is shown open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PopupInfo {
|
||||
/// `/Rect` of the `/Popup` annotation, in user space.
|
||||
pub rect: [f32; 4],
|
||||
/// `/Open` of the `/Popup` annotation.
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in
|
||||
/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a
|
||||
/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another
|
||||
/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be
|
||||
/// an indirect reference)".
|
||||
pub(crate) fn own_popup(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<ObjRef> {
|
||||
let pr = annot.reference(b"Popup")?;
|
||||
let is_own = doc.get(pr).as_dict().is_some_and(|pd| {
|
||||
pd.name(b"Subtype") == Some(b"Popup")
|
||||
&& pd
|
||||
.get(b"Parent")
|
||||
.is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own))))
|
||||
});
|
||||
is_own.then_some(pr)
|
||||
}
|
||||
|
||||
/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it
|
||||
/// is unreadable.
|
||||
pub(crate) fn popup_info(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<PopupInfo> {
|
||||
// Another annotation's rectangle and open state aren't this comment's (review on #2137).
|
||||
let r = own_popup(doc, own, annot)?;
|
||||
let p = doc.get(r);
|
||||
let p = p.as_dict()?;
|
||||
let rect: Vec<f32> = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect();
|
||||
// Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order.
|
||||
let [x0, y0, x1, y1] = rect[..] else { return None };
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true)));
|
||||
Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open })
|
||||
}
|
||||
|
||||
pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
@@ -64,7 +123,7 @@ pub fn is_comment_subtype(subtype: &str) -> bool {
|
||||
|
||||
/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect`
|
||||
/// follows it, within a few steps (a cycle stops there).
|
||||
fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||||
pub(crate) fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||||
for _ in 0..8 {
|
||||
match &*doc.get(r) {
|
||||
Object::Ref(next) => r = *next,
|
||||
@@ -74,6 +133,15 @@ fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether an annotation is a subordinate of a group rather than a reply: `/RT /Group` with an
|
||||
/// `/IRT` reference (ISO 32000-2 §12.5.6.2, Table 172). The group's primary is the `/IRT`
|
||||
/// annotation, and the subordinate's own text, author, colour, dates and pop-up are ignored.
|
||||
/// `/RT` is `R` by default, and a value other than `Group` reads as `R` too. Keep in step with
|
||||
/// `pdfcraft_render::inspect`.
|
||||
pub fn is_group_subordinate(d: &Dict) -> bool {
|
||||
d.name(b"RT") == Some(b"Group") && d.reference(b"IRT").is_some()
|
||||
}
|
||||
|
||||
/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge.
|
||||
pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
let mut out = Vec::new();
|
||||
@@ -130,15 +198,29 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Keep in step with `pdfcraft_render::inspect`.
|
||||
// Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a
|
||||
// readable text box, without which they can't be changed either.
|
||||
let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten();
|
||||
let points = match subtype.as_str() {
|
||||
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
|
||||
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
|
||||
"FreeText" => callout
|
||||
.as_ref()
|
||||
.and_then(|(line, _)| line.split_last())
|
||||
.map(|(_, lead)| lead.iter().map(|p| [p[0] as f32, p[1] as f32]).collect())
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let text_box = callout.map(|(_, t)| t.map(|v| v as f32));
|
||||
let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
|
||||
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||||
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
|
||||
// A group is one page's, as Acrobat reads it: a subordinate whose primary is on
|
||||
// another page is a comment of its own (keep in step with
|
||||
// `pdfcraft_render::link_replies`).
|
||||
let grouped = d.name(b"RT") == Some(b"Group");
|
||||
let cross_page = grouped && parent.is_some_and(|(p, _)| p != page);
|
||||
let (parent, in_reply_to) = if cross_page { (None, None) } else { (parent, in_reply_to) };
|
||||
out.push(Summary {
|
||||
page,
|
||||
index,
|
||||
@@ -151,7 +233,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
name: text_value(doc, d, b"NM"),
|
||||
in_reply_to,
|
||||
parent,
|
||||
grouped: d.name(b"RT") == Some(b"Group"),
|
||||
grouped,
|
||||
rect,
|
||||
color,
|
||||
// A reply that gives only `/StateModel` has the model's default state (ISO 32000-2
|
||||
@@ -167,6 +249,8 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
fill_sign: is_fill_sign(doc, d),
|
||||
no_rotate: drawn_upright(doc, d),
|
||||
points,
|
||||
text_box,
|
||||
popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -256,10 +340,18 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let direct = crate::draw::with_direct_values(doc, raw);
|
||||
let d = direct.as_ref();
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") };
|
||||
if !is_comment_subtype(&subtype) {
|
||||
return None;
|
||||
}
|
||||
// A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one).
|
||||
let style = (subtype == "FreeText").then(|| text_style::text_style(d, None));
|
||||
let color = match &style {
|
||||
Some(s) => Some(s.color),
|
||||
None => drawn_color(d, b"C"),
|
||||
};
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
|
||||
if subtype == "FreeText" {
|
||||
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
|
||||
appearance::border_width(d)
|
||||
} else {
|
||||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
|
||||
}
|
||||
@@ -276,7 +368,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
font_size: style.map(|s| s.size),
|
||||
border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(),
|
||||
border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)),
|
||||
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
|
||||
|
||||
@@ -29,9 +29,12 @@ mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
use create::num_array;
|
||||
pub use create::{add_annotation, callout_attach, orient_image_stamp};
|
||||
pub use create::{
|
||||
CALLOUT_KNEE_LINE, CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side,
|
||||
custom_stamp_size, orient_image_stamp,
|
||||
};
|
||||
pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached};
|
||||
pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries};
|
||||
pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries};
|
||||
#[cfg(test)]
|
||||
use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref};
|
||||
use lookup::{annots, page_ref};
|
||||
@@ -41,9 +44,9 @@ pub use lookup::{
|
||||
pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
|
||||
pub(crate) use modify::n;
|
||||
pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation,
|
||||
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect,
|
||||
set_review_state, set_style, with_replies_and_popups,
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members,
|
||||
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup,
|
||||
set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
|
||||
pub use types::{
|
||||
@@ -53,6 +56,7 @@ pub use types::{
|
||||
|
||||
pub mod appearance;
|
||||
pub mod links;
|
||||
pub mod text_style;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::info::text_value;
|
||||
use super::info::{is_comment_subtype, text_value};
|
||||
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, appearance};
|
||||
|
||||
@@ -89,6 +89,56 @@ thread_local! {
|
||||
pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Apple Markup's private copy of a page's markup: on the page and on each of its annotations.
|
||||
const APPLE_PAGE_MARKUP: [&[u8]; 2] = [b"AAPL:PPK", b"AAPL:PPKHash"];
|
||||
const APPLE_ANNOT_MARKUP: &[u8] = b"AAPL:AKExtras";
|
||||
|
||||
/// Drop Apple Markup's private copy of the page's markup once the annotation list changes. iOS
|
||||
/// draws Markup annotations from that copy, which no longer matches `/Annots`: iOS 26 showed a
|
||||
/// Markup signature in its own place and again over a signature added here. Without it, Apple
|
||||
/// draws every annotation from its appearance, as other viewers do. The drop is recorded in the
|
||||
/// document's repair log (iOS can no longer re-edit those strokes as live ink).
|
||||
fn drop_apple_markup(doc: &mut Document, page: ObjRef, list: &mut [Object]) -> Result<(), AnnotError> {
|
||||
let mut dropped = false;
|
||||
if doc.get(page).as_dict().is_some_and(|d| APPLE_PAGE_MARKUP.iter().any(|k| d.contains(k))) {
|
||||
doc.update_dict(page, |d| {
|
||||
for k in APPLE_PAGE_MARKUP {
|
||||
d.remove(k);
|
||||
}
|
||||
})?;
|
||||
dropped = true;
|
||||
}
|
||||
for entry in list {
|
||||
match entry {
|
||||
Object::Dict(d) if d.contains(APPLE_ANNOT_MARKUP) => {
|
||||
d.remove(APPLE_ANNOT_MARKUP);
|
||||
dropped = true;
|
||||
}
|
||||
Object::Ref(r) if doc.get(*r).as_dict().is_some_and(|d| d.contains(APPLE_ANNOT_MARKUP)) => {
|
||||
doc.update_dict(*r, |d| {
|
||||
d.remove(APPLE_ANNOT_MARKUP);
|
||||
})?;
|
||||
dropped = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
doc.note_repair(
|
||||
"dropped Apple Markup's private copy of a page's annotations after the page changed; iOS draws them from their appearance instead of as editable ink"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots` after an annotation was added, removed or answered: the point at
|
||||
/// which Apple Markup's private copy of the page goes stale (see [`drop_apple_markup`]).
|
||||
pub(crate) fn change_annots(doc: &mut Document, page: ObjRef, mut list: Vec<Object>) -> Result<(), AnnotError> {
|
||||
drop_apple_markup(doc, page, &mut list)?;
|
||||
Ok(set_annots(doc, page, list)?)
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty
|
||||
/// list removes a direct `/Annots`.
|
||||
pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result<(), pdfcraft_cos::CosError> {
|
||||
@@ -106,16 +156,23 @@ pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
|
||||
/// Whether the annotation dictionary `d` has a comment `/Subtype` ([`is_comment_subtype`]).
|
||||
pub(crate) fn is_comment_dict(d: &Dict) -> bool {
|
||||
d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s)))
|
||||
}
|
||||
|
||||
/// The comment annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
|
||||
pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let mut list = annots(doc, p);
|
||||
let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let r = match entry {
|
||||
Object::Ref(r) if doc.get(r).as_dict().is_some() => r,
|
||||
Object::Dict(d) => {
|
||||
Object::Ref(r) if doc.get(r).as_dict().is_some_and(is_comment_dict) => r,
|
||||
Object::Dict(d) if is_comment_dict(&d) => {
|
||||
let r = doc.add(Object::Dict(d));
|
||||
list[index] = Object::Ref(r);
|
||||
if let Some(slot) = list.get_mut(index) {
|
||||
*slot = Object::Ref(r);
|
||||
}
|
||||
set_annots(doc, p, list)?;
|
||||
r
|
||||
}
|
||||
|
||||
+354
-64
@@ -1,15 +1,17 @@
|
||||
use super::create::{
|
||||
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect,
|
||||
rect_for, rgb,
|
||||
CALLOUT_SLACK, add_annotation, base_dict, bounds, callout_attach, callout_knee, callout_side, ending_draws, ending_pair, finite, grow,
|
||||
ink_bounds, normalize, num_array, on_side, polygon_rect, rect_for, rgb,
|
||||
};
|
||||
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
use super::info::{line_endings_of, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
|
||||
use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, change_annots, is_fill_sign, page_ref, set_annots};
|
||||
use super::text_style::{self, TextStyle};
|
||||
use super::types::{
|
||||
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
|
||||
Shape, Style, StyleChange, TextReplacement,
|
||||
};
|
||||
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
|
||||
use pdfcraft_fonts::Std14;
|
||||
|
||||
/// 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.
|
||||
@@ -44,11 +46,36 @@ pub(crate) fn n(v: f64) -> String {
|
||||
|
||||
// ── edits ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
|
||||
/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing.
|
||||
/// [`rich_text_in`] for a text box whose `/DA` was just written (in Helvetica, as PdfCraft writes it).
|
||||
pub(crate) fn rich_text(d: &mut Dict) {
|
||||
let (color, size) = appearance::parse_da(d);
|
||||
rich_text_in(d, TextStyle { face: Std14::Helvetica, size, color });
|
||||
}
|
||||
|
||||
/// Give a text box the text `style`: `/DA` keeps the font resource it names (`/Helv` when it names
|
||||
/// none) and the border colour it gives (`RG`, [`appearance::da_border`]) with the new colour and
|
||||
/// size, and `/DS` and `/RC` carry the face, so the text keeps the face it is drawn in (ISO
|
||||
/// 32000-2 Table 177).
|
||||
fn restyle_text(d: &mut Dict, style: TextStyle) {
|
||||
let border = appearance::da_border(d);
|
||||
restyle_text_bordered(d, style, border);
|
||||
}
|
||||
|
||||
/// [`restyle_text`] with the border colour `border` (`None`: the text's).
|
||||
fn restyle_text_bordered(d: &mut Dict, style: TextStyle, border: Option<crate::Rgb>) {
|
||||
let font = text_style::da_font_name(d).unwrap_or_else(|| "Helv".into());
|
||||
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
|
||||
let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default();
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg {stroke}/{font} {} Tf", n(r), n(g), n(b), n(style.size)).into_bytes()));
|
||||
rich_text_in(d, TextStyle { color: [r, g, b], ..style });
|
||||
}
|
||||
|
||||
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
|
||||
/// plain `/Contents`, its text `style` and `/Q`, so viewers that edit rich text show the same thing.
|
||||
fn rich_text_in(d: &mut Dict, style: TextStyle) {
|
||||
let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default();
|
||||
let (col, size) = appearance::parse_da(d);
|
||||
let TextStyle { face, size, color: col } = style;
|
||||
let (family, weight, slant) = text_style::css(face);
|
||||
let hex = format!(
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
(col[0].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
@@ -63,13 +90,15 @@ pub(crate) fn rich_text(d: &mut Dict) {
|
||||
let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """);
|
||||
let body: String = text.split('\n').map(|line| format!("<p dir=\"ltr\">{}</p>", esc(line.trim_end_matches('\r')))).collect();
|
||||
let style = format!(
|
||||
"font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal",
|
||||
"font-size:{}pt;text-align:{align};color:{hex};font-weight:{weight};font-style:{slant};font-family:{family};font-stretch:normal",
|
||||
n(size)
|
||||
);
|
||||
let rc = format!(
|
||||
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:spec=\"2.0.2\" style=\"{style}\">{body}</body>"
|
||||
);
|
||||
d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size))));
|
||||
// "font: Helvetica 12pt" for PdfCraft's own boxes; other faces add their slant and weight.
|
||||
let keywords = format!("{}{}", if slant == "normal" { "" } else { "italic " }, if weight == "normal" { "" } else { "bold " });
|
||||
d.set(b"DS".to_vec(), PdfString::text(&format!("font: {keywords}{family} {}pt; text-align:{align}; color:{hex}", n(size))));
|
||||
d.set(b"RC".to_vec(), PdfString::text(&rc));
|
||||
}
|
||||
|
||||
@@ -80,33 +109,66 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
|
||||
}
|
||||
|
||||
/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does.
|
||||
/// Refused while it, or a reply that would go with it, is locked.
|
||||
/// Refused while it, or a reply that would go with it, is locked. A member of a group (`/RT
|
||||
/// /Group`), its primary included, goes alone, as Acrobat deletes the member clicked and keeps the
|
||||
/// rest of its group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act
|
||||
/// on the entire group; a subordinate left without its primary is a comment of its own and
|
||||
/// loses its `/IRT` ([`release_members`]). Replace Text's strikeout and caret are one comment and
|
||||
/// go together from either ([`own_deletion`]).
|
||||
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
|
||||
deletable(doc, page, index)?;
|
||||
lift_annotation(doc, page, index)
|
||||
let index = deleted_index(doc, page, index)?;
|
||||
lift_annotation(doc, page, index)?;
|
||||
// Not in lift_annotation: a drag preview lifts on a throwaway copy and must not log a repair.
|
||||
let p = page_ref(doc, page)?;
|
||||
change_annots(doc, p, annots(doc, p))
|
||||
}
|
||||
|
||||
/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without
|
||||
/// changing anything: what a Delete command asks before it is offered. It is refused while the
|
||||
/// annotation is locked, or a reply that would go with it is.
|
||||
pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> {
|
||||
deleted_index(doc, page, index).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`deletable`], returning the index [`delete_annotation`] deletes from: the caret for Replace
|
||||
/// Text's strikeout, else `index`.
|
||||
fn deleted_index(doc: &Document, page: usize, index: usize) -> Result<usize, AnnotError> {
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let mut target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let mut index = index;
|
||||
let target_obj = target.as_ref().map_or_else(|| doc.resolve(target), |r| doc.get(super::info::final_ref(doc, r)));
|
||||
if !target_obj.as_dict().is_some_and(super::lookup::is_comment_dict) {
|
||||
return Err(AnnotError::NoSuchAnnotation { page, index });
|
||||
}
|
||||
if let Some(r) = target.as_ref() {
|
||||
unlocked(doc, r)?;
|
||||
}
|
||||
// Replace Text is one comment: its strikeout goes with its caret, the group's primary, and
|
||||
// the caret with it, as Acrobat deletes the pair from either.
|
||||
let caret = doc
|
||||
.resolve(target)
|
||||
.as_dict()
|
||||
.filter(|d| is_group_subordinate(d) && !own_deletion(doc, d))
|
||||
.and_then(|d| d.reference(b"IRT"))
|
||||
.and_then(|irt| list.iter().enumerate().find(|(_, e)| e.as_ref() == Some(irt)));
|
||||
if let Some((i, e)) = caret {
|
||||
(target, index) = (e, i);
|
||||
}
|
||||
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
|
||||
// A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
|
||||
// answers either. Pop-ups follow their comment.
|
||||
for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
|
||||
// answers either (ISO 32000-2 §12.5.3, Table 167: Locked); nor does the other half of Replace
|
||||
// Text. Pop-ups follow their comment.
|
||||
for (i, e) in list.iter().enumerate().filter(|(i, e)| (*i != index || caret.is_some()) && goes_with(doc, e, &doomed)) {
|
||||
let obj = doc.resolve(e);
|
||||
if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice()))
|
||||
&& annotation_flags(doc, d) & FLAG_LOCKED != 0
|
||||
{
|
||||
return Err(AnnotError::Invalid("a reply to it is locked".into()));
|
||||
let grouped = is_group_subordinate(d) || caret.is_some_and(|(c, _)| c == i);
|
||||
let what = if grouped { "a comment in its group is locked" } else { "a reply to it is locked" };
|
||||
return Err(AnnotError::Invalid(what.into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as
|
||||
@@ -117,10 +179,42 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<
|
||||
let list = annots(doc, p);
|
||||
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
|
||||
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
|
||||
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(doc, e, &doomed)).map(|(_, e)| e.clone()).collect();
|
||||
let kept = release_members(doc, kept, &doomed)?;
|
||||
Ok(set_annots(doc, p, kept)?)
|
||||
}
|
||||
|
||||
/// The `/Annots` entries `kept` after deleting `going`, with every group subordinate that stays
|
||||
/// ([`own_deletion`]) but whose `/IRT` names an annotation going made a comment of its own: its
|
||||
/// `/IRT` is removed and its `/RT /Group` kept, as Acrobat does when a group's primary is deleted.
|
||||
/// A deleted primary then stays reachable from nothing, so a full save leaves its text out. An
|
||||
/// inline entry is rewritten in the list; a referenced one is updated in place. Each entry is
|
||||
/// read once, following no chain.
|
||||
pub fn release_members(doc: &mut Document, kept: Vec<Object>, going: &HashSet<ObjRef>) -> Result<Vec<Object>, pdfcraft_cos::CosError> {
|
||||
let orphaned = |d: &Dict| is_group_subordinate(d) && d.reference(b"IRT").is_some_and(|r| going.contains(&r));
|
||||
let mut out = Vec::with_capacity(kept.len());
|
||||
for e in kept {
|
||||
match e.as_ref() {
|
||||
Some(r) => {
|
||||
if doc.get(r).as_dict().is_some_and(orphaned) {
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(b"IRT");
|
||||
})?;
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
None => match e {
|
||||
Object::Dict(mut d) if orphaned(&d) => {
|
||||
d.remove(b"IRT");
|
||||
out.push(Object::Dict(d));
|
||||
}
|
||||
other => out.push(other),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
|
||||
/// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a
|
||||
/// Replace Text proposal also takes the caret it is grouped under, so the two go together
|
||||
@@ -144,16 +238,35 @@ fn replaced_by(doc: &Document, strike: &Object) -> Option<ObjRef> {
|
||||
/// one of them, or it is written inline (a file can, though the standard asks for references) and
|
||||
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
|
||||
/// for the walk to collect, so it is matched here.
|
||||
pub fn goes_with(entry: &Object, going: &HashSet<ObjRef>) -> bool {
|
||||
pub fn goes_with(doc: &Document, entry: &Object, going: &HashSet<ObjRef>) -> bool {
|
||||
match entry.as_ref() {
|
||||
Some(r) => going.contains(&r),
|
||||
None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))),
|
||||
None => entry.as_dict().is_some_and(|d| hangs_off(doc, d).into_iter().flatten().any(|r| going.contains(&r))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the annotation `d` goes with when it is deleted: what it replies to (`/IRT`) and what it
|
||||
/// pops up for (`/Parent`). A group subordinate (`/RT /Group`) is deleted on its own
|
||||
/// ([`own_deletion`]), so its `/IRT` doesn't count.
|
||||
fn hangs_off(doc: &Document, d: &Dict) -> [Option<ObjRef>; 2] {
|
||||
[d.reference(b"IRT").filter(|_| !own_deletion(doc, d)), d.reference(b"Parent")]
|
||||
}
|
||||
|
||||
/// Whether the annotation `d` stays when its group's primary is deleted: a group subordinate
|
||||
/// (`/RT /Group`) does, as Acrobat deletes only the member clicked, unless it is half of Replace
|
||||
/// Text, which Acrobat deletes as one comment: the strikeout (`/IT /StrikeOutTextEdit`) under its
|
||||
/// caret, or a caret under such a strikeout, as earlier PdfCraft builds wrote the pair.
|
||||
fn own_deletion(doc: &Document, d: &Dict) -> bool {
|
||||
let edit = |d: &Dict| d.name(b"IT") == Some(b"StrikeOutTextEdit".as_slice());
|
||||
let under_edit =
|
||||
|| d.name(b"Subtype") == Some(b"Caret".as_slice()) && d.reference(b"IRT").is_some_and(|r| doc.get(r).as_dict().is_some_and(edit));
|
||||
is_group_subordinate(d) && !edit(d) && !under_edit()
|
||||
}
|
||||
|
||||
/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or
|
||||
/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their
|
||||
/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no
|
||||
/// own replies, and the pop-ups going annotations name (`/Popup`). A group subordinate stays
|
||||
/// ([`own_deletion`]). An inline reply has no
|
||||
/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or
|
||||
/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also
|
||||
/// matches inline entries.
|
||||
@@ -165,7 +278,7 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
|
||||
// What goes with the annotation this entry hangs off: the entry, or for an inline one the
|
||||
// pop-up it names.
|
||||
let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue };
|
||||
for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() {
|
||||
for on in hangs_off(doc, d).into_iter().flatten() {
|
||||
hanging.entry(on).or_default().push(goes);
|
||||
}
|
||||
}
|
||||
@@ -186,15 +299,17 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
|
||||
/// around its text stays snug around the new text, centred where it was.
|
||||
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let free_text = d.name(b"Subtype") == Some(b"FreeText");
|
||||
let before = annot_dict(doc, r);
|
||||
let free_text = before.name(b"Subtype") == Some(b"FreeText");
|
||||
refuse_undrawable_thai(free_text, text)?;
|
||||
let snug = free_text && is_snug(&d);
|
||||
let snug = free_text && is_snug(&before);
|
||||
// The text keeps the look it is drawn with (`/DA`, or `/DS` where it sets one).
|
||||
let style = text_style::text_style(&before, text_style::dr_face(doc, &before));
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Contents".to_vec(), PdfString::text(text));
|
||||
// The rich-text version follows the new plain text (text boxes), or would contradict it.
|
||||
if free_text {
|
||||
rich_text(d);
|
||||
rich_text_in(d, style);
|
||||
} else {
|
||||
d.remove(b"RC");
|
||||
}
|
||||
@@ -227,7 +342,9 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
|
||||
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
|
||||
let d = annot_dict(doc, r);
|
||||
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
|
||||
let (_, size) = appearance::parse_da(&d);
|
||||
// Measured in the face and size the text is drawn in (`/DA`, or `/DS` where it sets one).
|
||||
let TextStyle { face, size, .. } = text_style::text_style(&d, text_style::dr_face(doc, &d));
|
||||
let measure = |s: &str, size: f64| appearance::text_width_in(face, s, size);
|
||||
let pad = 2.0 + border_width_of(&d);
|
||||
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
|
||||
let rect = [rect[0], rect[1], rect[2], rect[3]];
|
||||
@@ -245,10 +362,10 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
|
||||
w
|
||||
} else {
|
||||
// No usable old width: fall back to the creation-time width (at most 300 pt).
|
||||
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
|
||||
let longest = text.lines().map(|l| measure(l, size)).fold(0.0, f64::max);
|
||||
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
|
||||
};
|
||||
let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size);
|
||||
let height = appearance::text_lines_height(&pdfcraft_fonts::wrap_with(text, size, (w - 2.0 * pad).max(1.0), measure), size);
|
||||
Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top])
|
||||
}
|
||||
|
||||
@@ -315,7 +432,7 @@ fn reply(
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(r));
|
||||
let i = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
change_annots(doc, p, list)?;
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
@@ -328,6 +445,83 @@ pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> {
|
||||
if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Open, close or move a comment's pop-up window (Acrobat: double-click a comment to open it,
|
||||
/// drag its title bar to move it). `rect` is the pop-up's new `/Rect` in user space; `open` its
|
||||
/// `/Open`. A comment without a `/Popup` gets one, placed to the right of the comment as new
|
||||
/// notes get theirs. A text box, a Fill & Sign mark or a reply has no pop-up of its own (a reply
|
||||
/// shows in its parent's). The lock does not apply: the window's place and state are not the
|
||||
/// comment. The modification date is left alone: nothing of the comment changed.
|
||||
pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, open: Option<bool>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (p, r) = annot_ref(doc, page, index)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if !is_comment_subtype(&subtype) || subtype == "FreeText" || is_fill_sign(doc, &d) || d.contains(b"IRT") {
|
||||
return Err(AnnotError::Invalid(format!("{subtype} comments have no pop-up of their own")));
|
||||
}
|
||||
let rect = match rect {
|
||||
Some(rect) => {
|
||||
let rect = normalize(rect);
|
||||
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return Err(AnnotError::Invalid("invalid pop-up rectangle".into()));
|
||||
}
|
||||
Some(rect)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let _ = meta;
|
||||
// Where a new pop-up goes: to the right of the comment, as new notes' do.
|
||||
let own: Vec<f64> = d
|
||||
.get(b"Rect")
|
||||
.map(|o| doc.resolve(o))
|
||||
.and_then(|o| o.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|v| doc.resolve(v).as_f64())
|
||||
.collect();
|
||||
let beside = match own[..] {
|
||||
[_, _, x1, y1] if finite(&own) => [x1 + 10.0, y1 - 120.0, x1 + 210.0, y1],
|
||||
_ => [0.0, 0.0, 200.0, 120.0],
|
||||
};
|
||||
// Only a pop-up that is this comment's: `/Popup` naming another annotation (or a pop-up
|
||||
// whose `/Parent` is another comment) would move or open that one, so it gets its own.
|
||||
match own_popup(doc, Some(r), &d) {
|
||||
Some(pr) => {
|
||||
// One without a `/Rect` of four numbers reads as no pop-up (`summaries`), so it gets
|
||||
// the new pop-up's place: opening it then shows it.
|
||||
let rect = rect.or_else(|| popup_info(doc, Some(r), &d).is_none().then_some(beside));
|
||||
doc.update_dict(pr, |p| {
|
||||
if let Some(rect) = rect {
|
||||
p.set(b"Rect".to_vec(), num_array(&rect));
|
||||
}
|
||||
if let Some(open) = open {
|
||||
p.set(b"Open".to_vec(), Object::Bool(open));
|
||||
}
|
||||
})?
|
||||
}
|
||||
None => {
|
||||
let mut pd = Dict::new();
|
||||
pd.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
pd.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
pd.set(b"Rect".to_vec(), num_array(&rect.unwrap_or(beside)));
|
||||
pd.set(b"Parent".to_vec(), Object::Ref(r));
|
||||
pd.set(b"Open".to_vec(), Object::Bool(open.unwrap_or(false)));
|
||||
pd.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
let pref = doc.add(Object::Dict(pd));
|
||||
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(pref));
|
||||
set_annots(doc, p, list)?;
|
||||
}
|
||||
}
|
||||
// A note's own `/Open` says the same thing to viewers that read it there (§12.5.6.4).
|
||||
if let Some(open) = open
|
||||
&& (subtype == "Text" || d.contains(b"Open"))
|
||||
{
|
||||
doc.update_dict(r, |d| d.set(b"Open".to_vec(), Object::Bool(open)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized,
|
||||
/// restyled or deleted; its text and replies stay editable, as in Acrobat.
|
||||
pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> {
|
||||
@@ -358,7 +552,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(r));
|
||||
let i = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
change_annots(doc, p, list)?;
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
@@ -612,10 +806,12 @@ pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: Bo
|
||||
/// At most this many points reshape a comment (a drawn polygon has a handful).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
|
||||
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
|
||||
/// points with room for the line's width and endings, as when the comment was drawn, and the
|
||||
/// appearance is redrawn. A measurement keeps the points it measured.
|
||||
/// Reshape a line, connected lines, a polygon, a cloud or a callout: its points in user space (a
|
||||
/// line's two ends, the others' corners; a callout's arrow point and, if it has one, its knee), as
|
||||
/// dragging the comment's handles sets them. `/Rect` follows the points with room for the line's
|
||||
/// width and endings, as when the comment was drawn, and the appearance is redrawn. A callout's
|
||||
/// text box stays where it is and its leader line re-attaches. A measurement keeps the points it
|
||||
/// measured.
|
||||
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
@@ -626,6 +822,15 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64;
|
||||
if points.len() > MAX_POINTS {
|
||||
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
|
||||
}
|
||||
if d.name(b"Subtype") == Some(b"FreeText") && callout_line(doc, &d).is_some() {
|
||||
// A callout's points: its arrow point, then optionally its knee (a four-number `/CL`
|
||||
// gains one; ISO 32000-2 Table 177 allows both forms). The arrow point alone keeps a knee.
|
||||
if !matches!(points.len(), 1 | 2) || !finite(&points.concat()) {
|
||||
return Err(AnnotError::Invalid("a callout has an arrow point and, optionally, a knee".into()));
|
||||
}
|
||||
let text_box = callout_text_box(doc, &d).ok_or_else(|| AnnotError::Invalid("the callout's text box (/RD) can't be read".into()))?;
|
||||
return place_text_box(doc, r, text_box, Some(points), meta, false);
|
||||
}
|
||||
// The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's.
|
||||
let style = Style { width: appearance::border_width(&d), ..Style::default() };
|
||||
let ends = line_endings_of(&d).unwrap_or_default();
|
||||
@@ -700,6 +905,43 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
|
||||
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
|
||||
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
|
||||
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
|
||||
place_text_box(doc, r, rect, None, meta, new_text)
|
||||
}
|
||||
|
||||
/// A callout's leader line (`/CL`, ISO 32000-2 Table 177): four numbers (start, end) or six
|
||||
/// (start, knee, end), as points in user space. `None` when the annotation has no usable `/CL`.
|
||||
pub(crate) fn callout_line(doc: &Document, d: &Dict) -> Option<Vec<[f64; 2]>> {
|
||||
// All or nothing: a non-number would shift every later number into the wrong point.
|
||||
let l: Vec<f64> = doc.resolve(d.get(b"CL")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect::<Option<_>>()?;
|
||||
(matches!(l.len(), 4 | 6) && finite(&l)).then(|| l.as_chunks::<2>().0.to_vec())
|
||||
}
|
||||
|
||||
/// A callout's text box: `/Rect` inset by `/RD` (ISO 32000-2 Table 177). `None` without both.
|
||||
pub(crate) fn callout_text_box(doc: &Document, d: &Dict) -> Option<[f64; 4]> {
|
||||
let nums = |key: &[u8]| -> Option<Vec<f64>> { doc.resolve(d.get(key)?).as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect() };
|
||||
let r = nums(b"Rect").filter(|v| v.len() == 4 && finite(v))?;
|
||||
let rd = nums(b"RD").filter(|v| v.len() == 4 && finite(v) && v.iter().all(|x| *x >= 0.0))?;
|
||||
let r = normalize([r[0], r[1], r[2], r[3]]);
|
||||
let tb = [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]];
|
||||
(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb)
|
||||
}
|
||||
|
||||
/// As [`apply_text_box`]. For a callout, `lead` (its arrow point and optional knee, dragged by
|
||||
/// their handles) replaces the leader line's own points. The arrow point never moves with the box
|
||||
/// ("The text box moves around a stationary anchor point—the arrow on the end-point line",
|
||||
/// Acrobat DC Help, 11/28/2019, p. 357), and the line follows the box as Acrobat Pro DC (a 2021
|
||||
/// Continuous release) was seen to:
|
||||
/// - the box moved without changing size: the knee and the end move with it (also seen in
|
||||
/// Acrobat Pro 2026.002.22031);
|
||||
/// - the box resized with the side the line meets left where it was (the end still on it): the
|
||||
/// line doesn't change;
|
||||
/// - otherwise (that side moved): the line meets the middle of that side, the knee
|
||||
/// [`CALLOUT_KNEE_LINE`] out from it ([`callout_knee_on`]). Acrobat also moves the box next to
|
||||
/// the arrow point then; PdfCraft leaves the box where it was put.
|
||||
///
|
||||
/// With `lead`, the end stays where it is while it is on the side facing the knee (dragging the
|
||||
/// knee or the arrow point leaves it in Acrobat), and otherwise meets the middle of that side.
|
||||
fn place_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], lead: Option<&[[f64; 2]]>, meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
|
||||
let rect = normalize(rect);
|
||||
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||||
@@ -708,17 +950,62 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
if !new_text {
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
}
|
||||
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
|
||||
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
|
||||
let (outer, cl) = match callout {
|
||||
Some(mut l) => {
|
||||
let attach = callout_attach(rect, [l[2], l[3]]);
|
||||
(l[4], l[5]) = (attach[0], attach[1]);
|
||||
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
|
||||
let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad);
|
||||
([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l))
|
||||
// A callout's rectangle is its text box: `/Rect` grows to hold the leader line. A four-number
|
||||
// `/CL` (no knee) stays four numbers unless `lead` adds a knee.
|
||||
let old_line = callout_line(doc, &d);
|
||||
let old_box = callout_text_box(doc, &d);
|
||||
let old_end = old_line.as_deref().and_then(<[[f64; 2]]>::last).copied();
|
||||
// An arrow point alone moves the arrow: a knee the line has stays where it is.
|
||||
let kept: [[f64; 2]; 2];
|
||||
let lead = match (lead, old_line.as_deref()) {
|
||||
(Some([point]), Some([_, knee, _])) => {
|
||||
kept = [*point, *knee];
|
||||
Some(&kept[..])
|
||||
}
|
||||
None => (rect, None),
|
||||
_ => lead,
|
||||
};
|
||||
let line: Option<Vec<[f64; 2]>> = match (lead, old_line.as_deref()) {
|
||||
(Some(lead @ [.., toward]), _) => {
|
||||
let side = callout_side(rect, *toward);
|
||||
let end = old_end.filter(|e| on_side(rect, side, *e)).unwrap_or_else(|| callout_attach(rect, *toward));
|
||||
Some(lead.iter().copied().chain([end]).collect())
|
||||
}
|
||||
(Some(_), _) => None,
|
||||
(None, Some(old @ [start, .., end])) => {
|
||||
let (start, end) = (*start, *end);
|
||||
let knee = old.get(1).filter(|_| old.len() == 3).copied();
|
||||
let toward = knee.unwrap_or(start);
|
||||
Some(match old_box {
|
||||
// Moved, not resized: the knee and the end come along.
|
||||
Some(b)
|
||||
if ((rect[2] - rect[0]) - (b[2] - b[0])).abs() < CALLOUT_SLACK && ((rect[3] - rect[1]) - (b[3] - b[1])).abs() < CALLOUT_SLACK =>
|
||||
{
|
||||
let (dx, dy) = (rect[0] - b[0], rect[1] - b[1]);
|
||||
let shift = |p: [f64; 2]| [p[0] + dx, p[1] + dy];
|
||||
[start].into_iter().chain(knee.map(shift)).chain([shift(end)]).collect()
|
||||
}
|
||||
// Resized, the side the line meets still under its end: nothing changes.
|
||||
Some(b) if on_side(rect, callout_side(b, toward), end) => old.to_vec(),
|
||||
// That side moved: laid out again as a new callout is, from the side facing the
|
||||
// arrow point (the box may now be on the knee's other side).
|
||||
Some(_) => {
|
||||
let new_knee = callout_knee(rect, start);
|
||||
let end = callout_attach(rect, new_knee);
|
||||
[start].into_iter().chain(knee.map(|_| new_knee)).chain([end]).collect()
|
||||
}
|
||||
None => [start].into_iter().chain(knee).chain([callout_attach(rect, toward)]).collect(),
|
||||
})
|
||||
}
|
||||
(None, _) => None,
|
||||
};
|
||||
let (outer, cl) = match line.as_deref() {
|
||||
Some(line @ [lead @ .., _]) => {
|
||||
// The end is on the box, so only the arrow point and the knee need room around them.
|
||||
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
|
||||
let lead_box = grow(bounds(lead.iter().copied()).unwrap_or_default(), pad);
|
||||
([rect[0].min(lead_box[0]), rect[1].min(lead_box[1]), rect[2].max(lead_box[2]), rect[3].max(lead_box[3])], Some(line.concat()))
|
||||
}
|
||||
_ => (rect, None),
|
||||
};
|
||||
let (shape, width) = (BorderShape::of(&d), border_width_of(&d));
|
||||
let free_text = d.name(b"Subtype") == Some(b"FreeText");
|
||||
@@ -749,8 +1036,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border`
|
||||
/// (another app's), else 1.
|
||||
/// The width a comment's border is drawn with ([`appearance::border_width`]: `/BS /W`, else
|
||||
/// `/Border`'s third number, 1 when neither gives a usable one), so what is sized around it
|
||||
/// leaves room for what is drawn.
|
||||
fn border_width_of(d: &Dict) -> f64 {
|
||||
appearance::border_width(d)
|
||||
}
|
||||
@@ -857,6 +1145,8 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) ->
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let free_text = subtype == "FreeText";
|
||||
// The face and size the text is drawn in, which the new colour keeps.
|
||||
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
|
||||
// Typewriter text has no box of its own: a border goes around its text, so the box grows
|
||||
// (or shrinks) by the change on every side and the text keeps its place and its lines.
|
||||
// So does a text box inside an oval or a circle, which goes around it again.
|
||||
@@ -878,9 +1168,7 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) ->
|
||||
}
|
||||
if let Some(c) = color {
|
||||
if free_text {
|
||||
let (_, size) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size));
|
||||
rich_text(d);
|
||||
restyle_text(d, TextStyle { color: c, ..drawn });
|
||||
} else {
|
||||
d.set(b"C".to_vec(), rgb(c));
|
||||
}
|
||||
@@ -939,11 +1227,11 @@ pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Op
|
||||
return Err(AnnotError::Invalid("invalid colour".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
// The face, size and colour the text is drawn in, which the border colour keeps.
|
||||
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (text, size) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), free_text_da(text, color, size));
|
||||
rich_text(d);
|
||||
restyle_text_bordered(d, drawn, color);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
@@ -964,11 +1252,11 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let snug = is_snug(&d);
|
||||
// The face and colour the text is drawn in, which the new size keeps.
|
||||
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (col, _) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size));
|
||||
rich_text(d);
|
||||
restyle_text(d, TextStyle { size, ..drawn });
|
||||
touch(d, meta);
|
||||
})?;
|
||||
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
|
||||
@@ -1120,16 +1408,17 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
|
||||
}
|
||||
|
||||
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
|
||||
/// struck text holding `replacement`, the two grouped so they move, list and delete as one.
|
||||
/// Returns the strikeout's index.
|
||||
/// struck text holding `replacement`, grouped (ISO 32000-2 §12.5.6.2) so they list, select and
|
||||
/// delete as one. Returns the caret's index, the group's comment.
|
||||
///
|
||||
/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to
|
||||
/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary,
|
||||
/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has
|
||||
/// to be on the primary.
|
||||
/// The caret is the group's primary: it holds `replacement` in `/Contents` and has `/IT
|
||||
/// /Replace`. The strikeout is its subordinate (`/IRT` to the caret, `/RT /Group`, `/IT
|
||||
/// /StrikeOutTextEdit`) with no `/Contents`, since a reader ignores a subordinate's text.
|
||||
/// Groups written before this the other way round (the strikeout primary, the text only on
|
||||
/// the caret) still list their text: see `pdfcraft_render::fill_group_text`.
|
||||
///
|
||||
/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
|
||||
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
|
||||
/// to, so one id for both writes `inreplyto` equal to the strikeout'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.
|
||||
pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result<usize, AnnotError> {
|
||||
@@ -1168,8 +1457,9 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
|
||||
d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit"));
|
||||
d.set(b"IRT".to_vec(), Object::Ref(cr));
|
||||
d.set(b"RT".to_vec(), Object::name("Group"));
|
||||
d.remove(b"Contents");
|
||||
})?;
|
||||
Ok(s)
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points
|
||||
|
||||
+1329
-24
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,480 @@
|
||||
//! The face, size and colour a text box's text is drawn in when PdfCraft draws its appearance:
|
||||
//! `/DA` (ISO 32000-2 Table 177; the font named there is looked up in the interactive form's
|
||||
//! `/DR` font resources, §12.7.4.3) and `/DS`, the default style string (Table 177), which is
|
||||
//! read for its CSS `font`, `font-family`, `font-size`, `font-weight`, `font-style` and `color`.
|
||||
//!
|
||||
//! Only the twelve standard text faces are drawn (Helvetica, Times and Courier, each regular,
|
||||
//! bold, italic and bold italic, in WinAnsiEncoding): a font that isn't one of them is drawn in
|
||||
//! Helvetica, as before. Symbol and ZapfDingbats are not text faces for this purpose.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_fonts::{Std14, Std14Match as Match};
|
||||
|
||||
use crate::Rgb;
|
||||
use crate::appearance::parse_da;
|
||||
|
||||
/// `/DS` past this many bytes is read only this far (a style string is a line or two).
|
||||
const MAX_DS: usize = 4096;
|
||||
|
||||
/// Font sizes a text box is drawn at, as for `/DA`.
|
||||
const MAX_SIZE: f64 = 400.0;
|
||||
|
||||
/// How a text box's text is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
pub face: Std14,
|
||||
pub size: f64,
|
||||
pub color: Rgb,
|
||||
}
|
||||
|
||||
/// The style of `d`'s text: `/DA`, with what `/DS` sets taking its place. `dr_face` is the face
|
||||
/// the `/DA` font resource names in the form's `/DR` ([`dr_face`]); `None` when it isn't there,
|
||||
/// and the common resource names (`/Helv`, `/Cour`, `/TiRo`, …) are read instead.
|
||||
pub fn text_style(d: &Dict, dr_face: Option<Std14>) -> TextStyle {
|
||||
let (da_color, da_size) = parse_da(d);
|
||||
let da_face = dr_face.or_else(|| da_font_name(d).as_deref().and_then(resource_face)).unwrap_or(Std14::Helvetica);
|
||||
let mut style = TextStyle { face: da_face, size: da_size, color: da_color };
|
||||
let Some(ds) = d.get(b"DS").and_then(Object::as_string).map(|s| s.to_text()) else { return style };
|
||||
let ds = parse_ds(&ds);
|
||||
if let Some(size) = ds.size {
|
||||
style.size = size;
|
||||
}
|
||||
// `/DS` colours are 8-bit (`#RRGGBB`): one that rounds to the same as `/DA`'s keeps `/DA`'s.
|
||||
if let Some(color) = ds.color
|
||||
&& bytes(color) != bytes(da_color)
|
||||
{
|
||||
style.color = color;
|
||||
}
|
||||
let base = ds.face.unwrap_or(style.face);
|
||||
style.face = styled(base, ds.bold.unwrap_or(is_bold(base)), ds.italic.unwrap_or(is_italic(base)));
|
||||
style
|
||||
}
|
||||
|
||||
/// The face the `/DA` font of `d` names in the document's `/AcroForm /DR /Font` (§12.7.4.3):
|
||||
/// `Some` when that resource is there — the standard text face its `/BaseFont` names, or
|
||||
/// Helvetica for any other font — and `None` when it isn't.
|
||||
pub fn dr_face(doc: &Document, d: &Dict) -> Option<Std14> {
|
||||
let name = da_font_name(d)?;
|
||||
let root = doc.trailer().get(b"Root").map(|r| doc.resolve(r))?;
|
||||
let form = root.as_dict()?.get(b"AcroForm").map(|f| doc.resolve(f))?;
|
||||
let dr = form.as_dict()?.get(b"DR").map(|r| doc.resolve(r))?;
|
||||
let fonts = dr.as_dict()?.get(b"Font").map(|f| doc.resolve(f))?;
|
||||
let font = fonts.as_dict()?.get(name.as_bytes()).map(|f| doc.resolve(f))?;
|
||||
let font = font.as_dict()?;
|
||||
let base = font.name(b"BaseFont").map(String::from_utf8_lossy);
|
||||
Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica))
|
||||
}
|
||||
|
||||
/// The font resource name of the last `Tf` in `/DA` (without the slash). `Tf` takes a name and
|
||||
/// a size; a name given without its size (`/TiRo Tf`) is still read, and the size then falls back
|
||||
/// as [`parse_da`] says.
|
||||
pub fn da_font_name(d: &Dict) -> Option<String> {
|
||||
let da = d.get(b"DA").and_then(Object::as_string)?;
|
||||
let da = String::from_utf8_lossy(&da.bytes);
|
||||
let toks: Vec<&str> = da.split_whitespace().collect();
|
||||
let tf = toks.iter().rposition(|t| *t == "Tf")?;
|
||||
let name = match toks.get(..tf)? {
|
||||
[.., name, size] if !size.starts_with('/') => name,
|
||||
[.., name] => name,
|
||||
[] => return None,
|
||||
};
|
||||
let name = name.strip_prefix('/')?;
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The face a font resource name stands for: the names PDF writers commonly give the standard
|
||||
/// fonts in `/DR` (`Helv`, `HeBo`, `Cour`, `TiRo`, …), or a standard face's own name.
|
||||
pub fn resource_face(name: &str) -> Option<Std14> {
|
||||
Some(match name {
|
||||
"Helv" => Std14::Helvetica,
|
||||
"HeBo" => Std14::HelveticaBold,
|
||||
"HeOb" | "HeIt" => Std14::HelveticaOblique,
|
||||
"HeBO" | "HeBI" => Std14::HelveticaBoldOblique,
|
||||
"Cour" => Std14::Courier,
|
||||
"CoBo" => Std14::CourierBold,
|
||||
"CoOb" | "CoIt" => Std14::CourierOblique,
|
||||
"CoBO" | "CoBI" => Std14::CourierBoldOblique,
|
||||
"TiRo" => Std14::TimesRoman,
|
||||
"TiBo" => Std14::TimesBold,
|
||||
"TiIt" => Std14::TimesItalic,
|
||||
"TiBI" => Std14::TimesBoldItalic,
|
||||
_ => return base_font_face(name),
|
||||
})
|
||||
}
|
||||
|
||||
/// The standard text face a `/BaseFont` (or family) name asks for: the spec's names and their
|
||||
/// usual aliases (Arial, Times New Roman, Courier New), not a guess from a family hint.
|
||||
fn base_font_face(name: &str) -> Option<Std14> {
|
||||
match Std14::recognise(name)? {
|
||||
(Std14::Symbol | Std14::ZapfDingbats, _) | (_, Match::FamilyHint) => None,
|
||||
(face, Match::Alias) => Some(face),
|
||||
}
|
||||
}
|
||||
|
||||
/// `CSS` family names: the generic ones, else a standard face's name or alias.
|
||||
fn family_face(name: &str) -> Option<Std14> {
|
||||
let name = name.trim().trim_matches(|c| c == '"' || c == '\'').trim();
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"serif" => Some(Std14::TimesRoman),
|
||||
"monospace" => Some(Std14::Courier),
|
||||
"sans-serif" => Some(Std14::Helvetica),
|
||||
_ => base_font_face(name),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bold(f: Std14) -> bool {
|
||||
matches!(
|
||||
f,
|
||||
Std14::HelveticaBold
|
||||
| Std14::HelveticaBoldOblique
|
||||
| Std14::TimesBold
|
||||
| Std14::TimesBoldItalic
|
||||
| Std14::CourierBold
|
||||
| Std14::CourierBoldOblique
|
||||
)
|
||||
}
|
||||
|
||||
fn is_italic(f: Std14) -> bool {
|
||||
matches!(
|
||||
f,
|
||||
Std14::HelveticaOblique
|
||||
| Std14::HelveticaBoldOblique
|
||||
| Std14::TimesItalic
|
||||
| Std14::TimesBoldItalic
|
||||
| Std14::CourierOblique
|
||||
| Std14::CourierBoldOblique
|
||||
)
|
||||
}
|
||||
|
||||
/// `base`'s family in the given weight and slant.
|
||||
fn styled(base: Std14, bold: bool, italic: bool) -> Std14 {
|
||||
use Std14::*;
|
||||
match (base, bold, italic) {
|
||||
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, false) => TimesRoman,
|
||||
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, false) => TimesBold,
|
||||
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, true) => TimesItalic,
|
||||
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, true) => TimesBoldItalic,
|
||||
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, false) => Courier,
|
||||
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, false) => CourierBold,
|
||||
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, true) => CourierOblique,
|
||||
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, true) => CourierBoldOblique,
|
||||
(_, false, false) => Helvetica,
|
||||
(_, true, false) => HelveticaBold,
|
||||
(_, false, true) => HelveticaOblique,
|
||||
(_, true, true) => HelveticaBoldOblique,
|
||||
}
|
||||
}
|
||||
|
||||
/// The resource name an appearance stream gives `face` (the usual `/DR` names).
|
||||
pub fn resource_name(face: Std14) -> &'static str {
|
||||
match face {
|
||||
Std14::HelveticaBold => "HeBo",
|
||||
Std14::HelveticaOblique => "HeOb",
|
||||
Std14::HelveticaBoldOblique => "HeBO",
|
||||
Std14::TimesRoman => "TiRo",
|
||||
Std14::TimesBold => "TiBo",
|
||||
Std14::TimesItalic => "TiIt",
|
||||
Std14::TimesBoldItalic => "TiBI",
|
||||
Std14::Courier => "Cour",
|
||||
Std14::CourierBold => "CoBo",
|
||||
Std14::CourierOblique => "CoOb",
|
||||
Std14::CourierBoldOblique => "CoBO",
|
||||
_ => "Helv",
|
||||
}
|
||||
}
|
||||
|
||||
/// `face`'s `CSS` family, weight and style, for writing `/DS` and `/RC`.
|
||||
pub fn css(face: Std14) -> (&'static str, &'static str, &'static str) {
|
||||
let family = match styled(face, false, false) {
|
||||
Std14::TimesRoman => "Times",
|
||||
Std14::Courier => "Courier",
|
||||
_ => "Helvetica",
|
||||
};
|
||||
(family, if is_bold(face) { "bold" } else { "normal" }, if is_italic(face) { "italic" } else { "normal" })
|
||||
}
|
||||
|
||||
fn bytes(c: Rgb) -> [u8; 3] {
|
||||
// Clamped to 0..=1 first, so the product is 0..=255 and the cast exact.
|
||||
c.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
/// What a `/DS` string sets.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Ds {
|
||||
face: Option<Std14>,
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
size: Option<f64>,
|
||||
color: Option<Rgb>,
|
||||
}
|
||||
|
||||
/// Read a default style string: `;`-separated `CSS` declarations. Unknown or malformed ones are
|
||||
/// skipped; later ones win.
|
||||
fn parse_ds(ds: &str) -> Ds {
|
||||
let end = ds.char_indices().map(|(i, _)| i).find(|i| *i >= MAX_DS).unwrap_or(ds.len());
|
||||
let ds = ds.get(..end).unwrap_or_default();
|
||||
let mut out = Ds::default();
|
||||
for decl in ds.split(';') {
|
||||
let Some((prop, value)) = decl.split_once(':') else { continue };
|
||||
let value = value.trim();
|
||||
match prop.trim().to_ascii_lowercase().as_str() {
|
||||
"font" => font_shorthand(value, &mut out),
|
||||
"font-family" => {
|
||||
if let Some(face) = value.split(',').find_map(family_face) {
|
||||
out.face = Some(face);
|
||||
}
|
||||
}
|
||||
"font-size" => {
|
||||
if let Some(size) = font_size(value, true) {
|
||||
out.size = Some(size);
|
||||
}
|
||||
}
|
||||
"font-weight" => {
|
||||
if let Some(bold) = weight(value) {
|
||||
out.bold = Some(bold);
|
||||
}
|
||||
}
|
||||
"font-style" => {
|
||||
if let Some(italic) = slant(value) {
|
||||
out.italic = Some(italic);
|
||||
}
|
||||
}
|
||||
"color" => {
|
||||
if let Some(c) = css_color(value) {
|
||||
out.color = Some(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `font: [style] [weight] size[/line-height] family[, family…]`, read leniently: the size is
|
||||
/// the token with a unit, keywords set weight and slant, and the rest is the family list (some
|
||||
/// writers put the family first).
|
||||
fn font_shorthand(value: &str, out: &mut Ds) {
|
||||
let mut family = Vec::new();
|
||||
for tok in value.split_whitespace() {
|
||||
if let Some(size) = font_size(tok, false) {
|
||||
out.size = Some(size);
|
||||
} else if let Some(bold) = weight(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
|
||||
out.bold = Some(bold);
|
||||
} else if let Some(italic) = slant(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
|
||||
out.italic = Some(italic);
|
||||
} else if !matches!(tok.to_ascii_lowercase().as_str(), "normal" | "small-caps") {
|
||||
family.push(tok);
|
||||
}
|
||||
}
|
||||
if let Some(face) = family.join(" ").split(',').find_map(family_face) {
|
||||
out.face = Some(face);
|
||||
}
|
||||
}
|
||||
|
||||
/// A size in points: `14pt` (with a line height, `14pt/16pt`), or a bare number where
|
||||
/// `unitless` allows it. Positive and finite only; capped as `/DA` sizes are.
|
||||
fn font_size(tok: &str, unitless: bool) -> Option<f64> {
|
||||
let tok = tok.split('/').next()?.trim();
|
||||
let num = match tok.strip_suffix("pt").or_else(|| tok.strip_suffix("PT")) {
|
||||
Some(n) => n,
|
||||
None if unitless => tok,
|
||||
None => return None,
|
||||
};
|
||||
num.parse::<f64>().ok().filter(|s| s.is_finite() && *s > 0.0).map(|s| s.min(MAX_SIZE))
|
||||
}
|
||||
|
||||
fn weight(v: &str) -> Option<bool> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"bold" | "bolder" | "600" | "700" | "800" | "900" => Some(true),
|
||||
"normal" | "lighter" | "100" | "200" | "300" | "400" | "500" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn slant(v: &str) -> Option<bool> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"italic" | "oblique" => Some(true),
|
||||
"normal" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `#RRGGBB`, `#RGB` or `rgb(r, g, b)` (0–255 or percentages).
|
||||
fn css_color(v: &str) -> Option<Rgb> {
|
||||
let v = v.trim();
|
||||
if let Some(hex) = v.strip_prefix('#') {
|
||||
let digit = |c: char| c.to_digit(16).map(f64::from);
|
||||
let ds: Vec<f64> = hex.chars().map(digit).collect::<Option<_>>()?;
|
||||
return match ds.as_slice() {
|
||||
[r, g, b] => Some([r * 17.0 / 255.0, g * 17.0 / 255.0, b * 17.0 / 255.0]),
|
||||
[r1, r0, g1, g0, b1, b0] => Some([(r1 * 16.0 + r0) / 255.0, (g1 * 16.0 + g0) / 255.0, (b1 * 16.0 + b0) / 255.0]),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let lower = v.to_ascii_lowercase();
|
||||
let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?;
|
||||
let parts: Vec<f64> = inner
|
||||
.split(',')
|
||||
.map(|p| {
|
||||
let p = p.trim();
|
||||
let x = match p.strip_suffix('%') {
|
||||
Some(pc) => pc.trim().parse::<f64>().ok()? / 100.0,
|
||||
None => p.parse::<f64>().ok()? / 255.0,
|
||||
};
|
||||
x.is_finite().then(|| x.clamp(0.0, 1.0))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
match parts.as_slice() {
|
||||
[r, g, b] => Some([*r, *g, *b]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfcraft_cos::PdfString;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dict(da: &str, ds: Option<&str>) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"DA".to_vec(), PdfString::literal(da.as_bytes().to_vec()));
|
||||
if let Some(ds) = ds {
|
||||
d.set(b"DS".to_vec(), PdfString::text(ds));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn da_resource_names_pick_the_standard_faces() {
|
||||
assert_eq!(text_style(&dict("0 g /Cour 12 Tf", None), None).face, Std14::Courier);
|
||||
assert_eq!(text_style(&dict("1 0 0 rg /TiRo 16 Tf", None), None).face, Std14::TimesRoman);
|
||||
assert_eq!(text_style(&dict("/HeBo 9 Tf 0 g", None), None).face, Std14::HelveticaBold);
|
||||
assert_eq!(text_style(&dict("/Courier-Bold 9 Tf", None), None).face, Std14::CourierBold);
|
||||
// Unknown, symbolic and family-hint-only names keep Helvetica.
|
||||
for da in ["/F1 12 Tf", "/ZaDb 12 Tf", "/Symbol 12 Tf", "/MySerif 12 Tf", "12 Tf", "", "/ Tf"] {
|
||||
assert_eq!(text_style(&dict(da, None), None).face, Std14::Helvetica, "{da:?}");
|
||||
}
|
||||
// A `/DR` lookup outranks the name.
|
||||
assert_eq!(text_style(&dict("/Cour 12 Tf", None), Some(Std14::TimesRoman)).face, Std14::TimesRoman);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ds_font_size_and_colour_win_over_da() {
|
||||
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: Times 14pt; color: #008000")), None);
|
||||
assert_eq!((s.face, s.size), (Std14::TimesRoman, 14.0));
|
||||
assert!((s.color[1] - 128.0 / 255.0).abs() < 1e-9 && s.color[0] == 0.0 && s.color[2] == 0.0, "{:?}", s.color);
|
||||
let s = text_style(
|
||||
&dict(
|
||||
"1 0 0 rg /Cour 9 Tf",
|
||||
Some("font-family: 'Helvetica', sans-serif; font-size: 20; font-weight: bold; font-style: italic; color: rgb(0, 0, 255)"),
|
||||
),
|
||||
None,
|
||||
);
|
||||
assert_eq!((s.face, s.size, s.color), (Std14::HelveticaBoldOblique, 20.0, [0.0, 0.0, 1.0]));
|
||||
// CSS order, a line height, a #RGB colour and a generic family.
|
||||
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: italic bold 10pt/12pt serif; color:#f00")), None);
|
||||
assert_eq!((s.face, s.size, s.color), (Std14::TimesBoldItalic, 10.0, [1.0, 0.0, 0.0]));
|
||||
// Bold from the style keeps the DA family.
|
||||
assert_eq!(text_style(&dict("/Cour 12 Tf", Some("font-weight:700")), None).face, Std14::CourierBold);
|
||||
}
|
||||
|
||||
/// PdfCraft writes `/DS` from `/DA` (8-bit colour): `/DA`'s exact colour, size and face stay.
|
||||
#[test]
|
||||
fn pdfcraft_written_ds_matches_da() {
|
||||
let s = text_style(&dict("0.1 0.2 0.3 rg /Helv 12.5 Tf", Some("font: Helvetica 12.5pt; text-align:left; color:#1A334D")), None);
|
||||
assert_eq!(s, TextStyle { face: Std14::Helvetica, size: 12.5, color: [0.1, 0.2, 0.3] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ds_falls_back_to_da() {
|
||||
let da = TextStyle { face: Std14::Courier, size: 9.0, color: [1.0, 0.0, 0.0] };
|
||||
let huge = format!("color: #00ff00;{}", "x".repeat(1 << 20));
|
||||
let past_cap = format!("{}; font-size: 30pt", " ".repeat(MAX_DS));
|
||||
for ds in [
|
||||
"font-size: NaN",
|
||||
"font-size: inf",
|
||||
"font-size: -3pt",
|
||||
"font-size: 0",
|
||||
"font: NaNpt Zzz",
|
||||
"font: 1e999pt",
|
||||
"color: #12",
|
||||
"color: #zzzzzz",
|
||||
"color: rgb(1, 2)",
|
||||
"color: rgb(NaN, 0, 0)",
|
||||
"color: #ff0000é",
|
||||
":::;;;",
|
||||
"font-family: Wingdings, 'Comic Sans'",
|
||||
"é: ü; ;",
|
||||
past_cap.as_str(),
|
||||
] {
|
||||
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(ds)), None), da, "{ds:?}");
|
||||
}
|
||||
// Read up to the cap: what comes first still counts, and nothing past it is scanned.
|
||||
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(&huge)), None).color, [0.0, 1.0, 0.0]);
|
||||
// Sizes are capped as /DA's are.
|
||||
assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE);
|
||||
}
|
||||
|
||||
/// `/DA` strings as files give them: a `Tf` without a size, a zero size (auto-size in a form
|
||||
/// field, which a text box doesn't have), and hostile operands. None panics, each keeps what it
|
||||
/// can read, and nothing that isn't a finite number reaches the style.
|
||||
#[test]
|
||||
fn hostile_da_strings_keep_what_they_can() {
|
||||
let style = |da: &str| text_style(&dict(da, None), None);
|
||||
let black12 = |face| TextStyle { face, size: 12.0, color: [0.0; 3] };
|
||||
for (da, want) in [
|
||||
// A missing size keeps the named face at the default size.
|
||||
("/TiRo Tf", black12(Std14::TimesRoman)),
|
||||
("0 g /Cour Tf", black12(Std14::Courier)),
|
||||
// Zero, negative, NaN, infinite or not a number: the default size.
|
||||
("/Cour 0 Tf", black12(Std14::Courier)),
|
||||
("/Cour -0 Tf", black12(Std14::Courier)),
|
||||
("/Cour -12 Tf", black12(Std14::Courier)),
|
||||
("/Cour NaN Tf", black12(Std14::Courier)),
|
||||
("/Cour inf Tf", black12(Std14::Courier)),
|
||||
("/Cour -inf Tf", black12(Std14::Courier)),
|
||||
("/Cour 1e999 Tf", black12(Std14::Courier)),
|
||||
("/Cour twelve Tf", black12(Std14::Courier)),
|
||||
// No Tf at all, or one with nothing before it.
|
||||
("1 0 0 rg", TextStyle { color: [1.0, 0.0, 0.0], ..black12(Std14::Helvetica) }),
|
||||
("Tf", black12(Std14::Helvetica)),
|
||||
("Tf Tf Tf", black12(Std14::Helvetica)),
|
||||
("", black12(Std14::Helvetica)),
|
||||
// Colour operands that aren't finite numbers leave the colour black.
|
||||
("NaN NaN NaN rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
|
||||
("NaN g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
|
||||
("inf 0 0 rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
|
||||
("rg g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
|
||||
// Out-of-range colour components are clamped.
|
||||
("2 -1 0.5 rg /Helv 9 Tf", TextStyle { size: 9.0, color: [1.0, 0.0, 0.5], ..black12(Std14::Helvetica) }),
|
||||
// Huge sizes are capped.
|
||||
("/Helv 1e300 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
|
||||
("/Helv 100000 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
|
||||
// Garbage around a valid Tf: the last Tf is read.
|
||||
("\u{0} ] [ << /Helv 9 Tf >> /Cour 14 Tf )", TextStyle { size: 14.0, ..black12(Std14::Courier) }),
|
||||
] {
|
||||
assert_eq!(style(da), want, "{da:?}");
|
||||
let s = style(da);
|
||||
assert!(s.size.is_finite() && s.size > 0.0 && s.color.iter().all(|c| (0.0..=1.0).contains(c)), "{da:?}: {s:?}");
|
||||
}
|
||||
// A font name that is no standard face and that no `/DR` lists keeps Helvetica.
|
||||
assert_eq!(style("0 g /F9 11 Tf"), TextStyle { size: 11.0, ..black12(Std14::Helvetica) });
|
||||
// Binary bytes that aren't UTF-8 don't panic.
|
||||
let mut d = Dict::new();
|
||||
d.set(b"DA".to_vec(), PdfString::literal(vec![0xFF, 0xFE, b' ', b'/', 0xC3, b' ', b'9', b' ', b'T', b'f']));
|
||||
assert_eq!(text_style(&d, None).size, 9.0);
|
||||
assert_eq!(da_font_name(&dict("/TiRo Tf", None)).as_deref(), Some("TiRo"));
|
||||
assert_eq!(da_font_name(&dict("12 Tf", None)), None);
|
||||
assert_eq!(da_font_name(&dict("0 g Tf", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_round_trips_through_the_parser() {
|
||||
for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] {
|
||||
let (family, weight, style) = css(face);
|
||||
let ds = format!("font-family:{family};font-weight:{weight};font-style:{style}");
|
||||
assert_eq!(text_style(&dict("/Helv 9 Tf", Some(&ds)), None).face, face);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,13 +417,17 @@ pub enum Shape {
|
||||
contours: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
/// A custom stamp: a picture already in the document (an image XObject, or a form XObject
|
||||
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`.
|
||||
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. With `line`, a
|
||||
/// line of text under the picture (#1741): a dynamic stamp's name, date and time, filled in
|
||||
/// when it was placed; the picture then fills the rectangle above it
|
||||
/// ([`CUSTOM_LINE_BAND`](crate::CUSTOM_LINE_BAND)).
|
||||
CustomStamp {
|
||||
rect: [f64; 4],
|
||||
name: String,
|
||||
picture: ObjRef,
|
||||
image: bool,
|
||||
size: (f64, f64),
|
||||
line: Option<String>,
|
||||
},
|
||||
/// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad
|
||||
/// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`.
|
||||
|
||||
@@ -207,8 +207,9 @@ mutually exclusive with `hide_text_block`.
|
||||
|
||||
When the renderer reports a warning for the page, `page_render` and `render_preview` return a
|
||||
JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences
|
||||
`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour
|
||||
that could not be computed was painted in a fallback colour". A page without warnings returns
|
||||
`pdfcraft-cli render` prints, such as "content past the page's safety budget, or a picture whose
|
||||
JBIG2 mask is past a decoder limit, was skipped" and "a colour that could not be computed was
|
||||
painted in a fallback colour". A page without warnings returns
|
||||
the PNG alone.
|
||||
|
||||
|
||||
@@ -332,6 +333,14 @@ navigation-history tool; use the opt-in UI control channel to navigate the displ
|
||||
FreeText appearance and survives reopening. Other mark/signature types reject
|
||||
`color`; their appearance controls are separate.
|
||||
|
||||
## AcroForm script interactions
|
||||
|
||||
`js_run` accepts optional `answers` (strings or null, at most 32), consumed in popup, response and alert order. Missing responses cancel menus/input; explicit viewer requests remain data in automation. See [the runtime contract](../../docs/acroform-script-workflows.md).
|
||||
|
||||
For distinct PDF fields sharing a name, form_fields exposes a document-local integer field_id. Pass it with field to js_run to bind event.target to that exact field (including ordered answers); omit it for existing first-match behavior. Mismatched names/IDs, missing names and non-integer/out-of-range IDs are refused before execution.
|
||||
|
||||
`form_set_image` accepts the optional document-local `field_id` from `form_fields` alongside the matching `field` name, selecting an exact duplicate-name image button. Omit it for the legacy first-name match. IDs are unsigned 48-bit integers and must be rediscovered after structural edits or reopening.
|
||||
|
||||
## Reviewing OCR headlessly
|
||||
|
||||
`ocr_words {doc, retain_review: true}` reads words without editing the document and returns
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::collections::BTreeSet;
|
||||
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
|
||||
|
||||
mod documents;
|
||||
mod form_tools;
|
||||
pub(crate) mod form_tools;
|
||||
mod ocr;
|
||||
|
||||
fn status_id(s: Status) -> &'static str {
|
||||
|
||||
@@ -118,6 +118,9 @@ impl Automation {
|
||||
Ok(json!({ "actions": actions, "steps": steps }))
|
||||
}
|
||||
|
||||
/// `action_run`: run a named action or a list of steps on each file in `paths`, writing the
|
||||
/// results into `folder`; each file is processed in its own session that keeps this
|
||||
/// session's JavaScript preference.
|
||||
pub(crate) fn action_run(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::actions::{Action, Step, builtin, run_on};
|
||||
let action = if let Some(name) = a.opt_str("action")? {
|
||||
@@ -138,6 +141,8 @@ impl Automation {
|
||||
};
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
// Each file runs in its own session; it keeps this one's JavaScript preference.
|
||||
let javascript = self.session.javascript();
|
||||
let mut out = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
|
||||
@@ -145,7 +150,7 @@ impl Automation {
|
||||
.resolve(p, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string()))
|
||||
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
|
||||
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), javascript, |_, _| {}));
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, 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.
|
||||
/// A script's request to the viewer as the JSON that running a script reports (page numbers
|
||||
/// 1-based); a side panel goes by its fixed id.
|
||||
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
use pdfcraft_engine::js::Request as R;
|
||||
match r {
|
||||
@@ -11,26 +11,66 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
R::LaunchUrl(u) => json!({ "url": u }),
|
||||
R::Submit(u) => json!({ "submit": u }),
|
||||
R::Focus(f) => json!({ "focus": f }),
|
||||
R::FocusField { name, object_id } => json!({ "focus": name, "field_id": object_id }),
|
||||
R::Zoom(z) => json!({"zoom":z}),
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::Highlight(on) => json!({ "highlight": on }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The optional `field_id` argument: a field's identity from form_fields, selecting one of
|
||||
/// fields sharing a name.
|
||||
fn field_id(a: &Args) -> Result<Option<u64>> {
|
||||
a.0.get("field_id")
|
||||
.map(|v| {
|
||||
v.as_u64()
|
||||
.filter(|id| *id < 1 << 48)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into()))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
|
||||
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
|
||||
}
|
||||
|
||||
/// What scripts that ran unasked (a document's Will Save or Will Print actions) showed, said
|
||||
/// or asked for, as tools report it.
|
||||
pub(crate) fn js_output_json(out: &pdfcraft_engine::js::JsOutput) -> Value {
|
||||
json!({
|
||||
"alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(),
|
||||
"console": out.console,
|
||||
"requests": out.requests.iter().map(request_json).collect::<Vec<_>>(),
|
||||
"errors": out.errors,
|
||||
})
|
||||
}
|
||||
|
||||
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 answers = match a.0.get("answers") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(values)) if values.len() <= 32 => values
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())),
|
||||
_ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
_ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())),
|
||||
};
|
||||
let identity = field_id(a)?;
|
||||
let (script, field) = (a.str("script")?, a.opt_str("field")?);
|
||||
// Applying the script's field changes runs the field scripts again; their output is reported with the script's.
|
||||
let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript(id, script, field)).map_err(failed)?;
|
||||
let (mut o, edit) =
|
||||
self.session.with_own_js_output(id, |s| s.run_javascript_answers_target(id, script, field, identity, &answers)).map_err(failed)?;
|
||||
o.alerts.extend(edit.alerts);
|
||||
o.console.extend(edit.console);
|
||||
o.requests.extend(edit.requests);
|
||||
@@ -47,6 +87,8 @@ impl Automation {
|
||||
Ok(json!({ "scripts": list }))
|
||||
}
|
||||
|
||||
/// `js_set_document_script`: set the document-level script `name` to `script`, or remove it
|
||||
/// when `script` is left out, as one undoable edit; returns the document's scripts.
|
||||
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
|
||||
let id = self.doc(a)?.id;
|
||||
@@ -54,6 +96,8 @@ impl Automation {
|
||||
Ok(with_warnings(self.js_document_scripts(a)?, warnings))
|
||||
}
|
||||
|
||||
/// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when
|
||||
/// `script` is left out, as one undoable edit; returns the console output and errors.
|
||||
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetFieldScript {
|
||||
name: a.str("field")?.into(),
|
||||
@@ -65,6 +109,70 @@ impl Automation {
|
||||
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
|
||||
}
|
||||
|
||||
/// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`)
|
||||
/// focus, running its XFA scripts for that event; returns whether the form changed and what
|
||||
/// the scripts showed or asked for.
|
||||
pub(crate) fn form_focus(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::js::FieldFocus;
|
||||
let focus = match a.str("event")? {
|
||||
"enter" => FieldFocus::Enter,
|
||||
"exit" => FieldFocus::Exit,
|
||||
other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))),
|
||||
};
|
||||
let name = a.str("field")?;
|
||||
let doc = self.doc(a)?;
|
||||
let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
|
||||
// A field with no widget (one only scripts or calculations use) has nothing to focus.
|
||||
let widgets = field.widgets.len();
|
||||
let widget = a.opt_int("widget")?.unwrap_or(1);
|
||||
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
|
||||
0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")),
|
||||
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
|
||||
})?;
|
||||
let id = doc.id;
|
||||
let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?;
|
||||
let out = self.session.take_js_output(id);
|
||||
let reqs: Vec<Value> = out.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(), "console": out.console, "requests": reqs, "errors": out.errors }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `js_event`: run the JavaScript widget `widget` (1-based) of field `field` has for `event`
|
||||
/// (a trigger id: mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus, on_blur); returns
|
||||
/// whether the form changed and what the script showed or asked for.
|
||||
pub(crate) fn js_event(&mut self, a: &Args) -> Result<Value> {
|
||||
let event = a.str("event")?;
|
||||
let trigger = pdfcraft_engine::FieldTrigger::from_id(event).ok_or_else(|| {
|
||||
ToolError::InvalidArgs(format!("event must be mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus or on_blur, not {event:?}"))
|
||||
})?;
|
||||
let name = a.str("field")?;
|
||||
let identity = field_id(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
let field =
|
||||
doc.form.iter().find(|f| f.name == name && identity.is_none_or(|i| pdfcraft_engine::js::field_object_id(f.obj) == i)).ok_or_else(
|
||||
|| match identity {
|
||||
Some(i) => failed(format!("there is no field named {name:?} with field_id {i} (see form_fields)")),
|
||||
None => failed(format!("there is no field named {name:?} (see form_fields)")),
|
||||
},
|
||||
)?;
|
||||
let widgets = field.widgets.len();
|
||||
let widget = a.opt_int("widget")?.unwrap_or(1);
|
||||
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
|
||||
0 => ToolError::InvalidArgs(format!("{name:?} has no widget")),
|
||||
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
|
||||
})?;
|
||||
let (id, object) = (doc.id, pdfcraft_engine::js::field_object_id(field.obj));
|
||||
// Only this event's output: what opening or an earlier tool left waiting stays.
|
||||
let (changed, out) =
|
||||
self.session.with_own_js_output(id, |s| s.field_trigger_target(id, name, Some(object), widget, trigger)).map_err(failed)?;
|
||||
let mut result = js_output_json(&out);
|
||||
result["changed"] = json!(changed);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
|
||||
/// file; returns the path and the row and column counts.
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
|
||||
@@ -164,6 +164,8 @@ impl Automation {
|
||||
}
|
||||
// The engines are built (and named if they cannot be) before the first file is read.
|
||||
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
|
||||
// Each file opens in its own session; it keeps this one's JavaScript preference.
|
||||
let javascript = self.session.javascript();
|
||||
let mut out = Vec::new();
|
||||
let (mut ok, mut skipped_files, mut words) = (0usize, 0usize, 0usize);
|
||||
for p in paths {
|
||||
@@ -171,7 +173,15 @@ impl Automation {
|
||||
let result =
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|
||||
|bytes| {
|
||||
pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &recognizers, |_, _| true)
|
||||
pdfcraft_engine::ocr::recognize_file(
|
||||
&name,
|
||||
std::sync::Arc::new(bytes),
|
||||
None,
|
||||
settings.clone(),
|
||||
&recognizers,
|
||||
javascript,
|
||||
|_, _| true,
|
||||
)
|
||||
},
|
||||
);
|
||||
match result {
|
||||
|
||||
@@ -239,6 +239,8 @@ impl Automation {
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
/// `page_extract`: the chosen pages as one new PDF, or with `separate` one file per page in
|
||||
/// `out_dir`; with `delete` the pages are then removed from the original.
|
||||
pub(crate) fn page_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, name) = (doc.id, format!("{} (extract)", doc.name));
|
||||
@@ -246,16 +248,8 @@ impl Automation {
|
||||
let pages = self.pages(a, "pages")?;
|
||||
let mut out = if a.opt_bool("separate")?.unwrap_or(false) {
|
||||
// Each page as its own file.
|
||||
let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let mut files = Vec::new();
|
||||
for &p in &pages {
|
||||
let bytes = self.session.extract(id, &[p]).map_err(failed)?;
|
||||
let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1));
|
||||
write_atomic(&path, &bytes)?;
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
json!({ "files": files })
|
||||
let out_dir = a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?;
|
||||
json!({ "files": self.extract_separate(id, &pages, out_dir, &stem)? })
|
||||
} else {
|
||||
let bytes = self.session.extract(id, &pages).map_err(failed)?;
|
||||
self.deliver(a, &name, bytes)?
|
||||
@@ -267,6 +261,45 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Each of `pages` as its own `<stem>-page<n>.pdf` in `out_dir`, extracted and written one at a
|
||||
/// time, so only one page's PDF is held at once. The first page is extracted before `out_dir` is
|
||||
/// resolved and made, so a refusal (the permissions refuse every page alike, #5081) leaves nothing
|
||||
/// behind; if a later page fails, the files this call created, and the directory if it made it,
|
||||
/// are removed.
|
||||
fn extract_separate(&self, id: pdfcraft_engine::DocId, pages: &[usize], out_dir: &str, stem: &str) -> Result<Vec<String>> {
|
||||
let Some(&first) = pages.first() else { return Ok(Vec::new()) };
|
||||
let mut first_bytes = Some(self.session.extract(id, &[first]).map_err(failed)?);
|
||||
let dir = self.resolve(out_dir, true)?;
|
||||
let made_dir = !dir.exists();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let (mut files, mut created) = (Vec::new(), Vec::new());
|
||||
let outcome = pages.iter().try_for_each(|&p| -> Result<()> {
|
||||
let bytes = match first_bytes.take() {
|
||||
Some(bytes) => bytes,
|
||||
None => self.session.extract(id, &[p]).map_err(failed)?,
|
||||
};
|
||||
let path = child(&dir, &format!("{stem}-page{}.pdf", p.saturating_add(1)));
|
||||
let existed = path.exists();
|
||||
write_atomic(&path, &bytes)?;
|
||||
if !existed {
|
||||
created.push(path.clone());
|
||||
}
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
// Best effort: whatever can't be removed stays, and the error reported is the extraction's.
|
||||
for path in &created {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
if made_dir {
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_combine(&mut self, a: &Args) -> Result<Value> {
|
||||
let paths = a.strs("paths")?;
|
||||
if paths.len() < 2 {
|
||||
|
||||
@@ -19,9 +19,13 @@ struct Recipe<'a> {
|
||||
images: optimize::ImageSettings,
|
||||
mono: optimize::MonoSettings,
|
||||
ocr: ocr::OcrSettings,
|
||||
/// The calling session's JavaScript preference, carried into each file's own session.
|
||||
javascript: bool,
|
||||
}
|
||||
|
||||
impl<'a> Recipe<'a> {
|
||||
/// Read and validate `doc_batch`'s steps and settings, taking the JavaScript preference from
|
||||
/// `auto`'s session.
|
||||
fn parse(auto: &Automation, args: &'a Args) -> Result<Self> {
|
||||
let steps = args.strs("steps")?;
|
||||
if steps.is_empty() || steps.len() > 3 {
|
||||
@@ -60,7 +64,7 @@ impl<'a> Recipe<'a> {
|
||||
if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) {
|
||||
return Err(Args::wrong("dpi", "72 to 600"));
|
||||
}
|
||||
Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? })
|
||||
Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)?, javascript: auto.session.javascript() })
|
||||
}
|
||||
|
||||
fn accepts(&self, path: &Path) -> bool {
|
||||
@@ -68,9 +72,13 @@ impl<'a> Recipe<'a> {
|
||||
extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str()))
|
||||
}
|
||||
|
||||
/// Run the recipe's steps on the file at `path` in a fresh session; returns the output bytes
|
||||
/// and a report entry per step.
|
||||
fn process(&self, path: &Path) -> Result<(Arc<Vec<u8>>, Vec<Value>)> {
|
||||
// A fresh session per file bounds retained document state and never touches caller tabs.
|
||||
// It keeps the caller's JavaScript preference, set before any open runs open-time scripts.
|
||||
let mut session = Session::new();
|
||||
session.set_javascript(self.javascript);
|
||||
let mut input = Vec::new();
|
||||
std::fs::File::open(path).map_err(failed)?.take(MAX_INPUT + 1).read_to_end(&mut input).map_err(failed)?;
|
||||
if input.len() as u64 > MAX_INPUT {
|
||||
@@ -85,7 +93,8 @@ impl<'a> Recipe<'a> {
|
||||
"convert" => bytes = session.convert_to_pdf(name, &bytes)?.1,
|
||||
"ocr" => {
|
||||
let recognizers = ocr::recognizers(&self.ocr).map_err(failed)?;
|
||||
let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, |_, _| true).map_err(failed)?;
|
||||
let result =
|
||||
ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, self.javascript, |_, _| true).map_err(failed)?;
|
||||
log.push(
|
||||
json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}),
|
||||
);
|
||||
|
||||
@@ -366,7 +366,9 @@ mod tests {
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
// Not on macOS: APFS keeps only UTF-8 file names, so no output there can have such a name, and
|
||||
// creating the probe fails before the check runs.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn output_probe_paths_preserve_non_utf8_names() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
@@ -179,7 +179,21 @@ impl Automation {
|
||||
let doc = self.doc(a)?;
|
||||
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
|
||||
let all = self.comments(a)?;
|
||||
// A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) lists as its primary, with the primary's
|
||||
// attributes: the subordinates are neither comments nor replies, only its `group`.
|
||||
let mut members: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
|
||||
for (m, p) in all.iter().zip(pdfcraft_render::group_primaries(&all)) {
|
||||
if let Some(p) = p.and_then(|p| all.get(p)) {
|
||||
members.entry((p.page, p.index)).or_default().push(m);
|
||||
}
|
||||
}
|
||||
let view = |c: &Annotation| -> Value {
|
||||
let group: Vec<Value> = members
|
||||
.get(&(c.page, c.index))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|m| json!({ "id": m.name, "page": m.page + 1, "index": m.index + 1, "type": m.subtype }))
|
||||
.collect();
|
||||
let mut v = json!({
|
||||
"id": c.name,
|
||||
"page": c.page + 1,
|
||||
@@ -191,6 +205,9 @@ impl Automation {
|
||||
"modified": c.modified,
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
|
||||
"color": c.color.map(hex),
|
||||
"group": group,
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
"popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })),
|
||||
});
|
||||
// A text box's or typed text's oval or circle (a rectangle is not reported).
|
||||
if c.subtype == "FreeText"
|
||||
@@ -211,8 +228,8 @@ impl Automation {
|
||||
v["text_opacity"] = json!(t);
|
||||
}
|
||||
}
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
// comment_edit's `points` takes them.
|
||||
// A line's ends, the corners of connected lines, a polygon or a cloud, or a callout's
|
||||
// arrow point and knee, as comment_edit's `points` takes them.
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
if !c.points.is_empty() {
|
||||
let round = |v: f32| (v * 100.0).round() / 100.0;
|
||||
@@ -220,15 +237,20 @@ impl Automation {
|
||||
v["points"] =
|
||||
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
|
||||
}
|
||||
// A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line).
|
||||
if let Some(tb) = c.text_box {
|
||||
v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb));
|
||||
}
|
||||
v
|
||||
};
|
||||
// Every reply under the comment its thread starts from, at any depth, in document order:
|
||||
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
|
||||
// drop it. Parents are found by reference, as `/NM` need not be unique. Status and
|
||||
// checkmark replies are read through `review` instead.
|
||||
// checkmark replies are read through `review` instead. A group subordinate is in no
|
||||
// thread, and a reply to one is the group's, its primary's.
|
||||
let tops = pdfcraft_render::thread_tops(&all);
|
||||
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
|
||||
for r in all.iter().filter(|r| r.state.is_none()) {
|
||||
for r in all.iter().filter(|r| r.state.is_none() && !r.is_group_member()) {
|
||||
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
|
||||
threads.entry(top).or_default().push(r);
|
||||
}
|
||||
@@ -408,18 +430,8 @@ impl Automation {
|
||||
let point = to_user(&info, t[0], t[1]);
|
||||
let knee = match a.nums::<2>("knee")? {
|
||||
Some(k) => to_user(&info, k[0], k[1]),
|
||||
// Halfway between the point and the box, level with the box's middle.
|
||||
None => {
|
||||
let mid = (rect[1] + rect[3]) / 2.0;
|
||||
let side = if point[0] < rect[0] {
|
||||
rect[0]
|
||||
} else if point[0] > rect[2] {
|
||||
rect[2]
|
||||
} else {
|
||||
(rect[0] + rect[2]) / 2.0
|
||||
};
|
||||
[(point[0] + side) / 2.0, mid]
|
||||
}
|
||||
// Acrobat's: 12 pt out from the middle of the side facing the point.
|
||||
None => pdfcraft_engine::callout_knee(rect, point),
|
||||
};
|
||||
Shape::Callout {
|
||||
rect,
|
||||
@@ -700,6 +712,17 @@ impl Automation {
|
||||
self.apply(a, Edit::MarkAnnotation { page, index, marked, author })
|
||||
}
|
||||
|
||||
pub(crate) fn comment_popup(&mut self, a: &Args) -> Result<Value> {
|
||||
let (page, index) = self.comment_target(a)?;
|
||||
let info = self.doc(a)?.info.pages[page].clone();
|
||||
let open = a.opt_bool("open")?;
|
||||
let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r));
|
||||
if open.is_none() && rect.is_none() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into()));
|
||||
}
|
||||
self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open })
|
||||
}
|
||||
|
||||
pub(crate) fn comment_lock(&mut self, a: &Args) -> Result<Value> {
|
||||
let (page, index) = self.comment_target(a)?;
|
||||
let locked = a.opt_bool("locked")?.unwrap_or(true);
|
||||
@@ -873,6 +896,7 @@ impl Automation {
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
|
||||
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
|
||||
let edit = Edit::AddCustomStamp {
|
||||
page,
|
||||
rect,
|
||||
@@ -882,7 +906,16 @@ impl Automation {
|
||||
bytes: std::sync::Arc::new(bytes),
|
||||
page: a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1,
|
||||
},
|
||||
author: a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(),
|
||||
line: None,
|
||||
author: author.clone(),
|
||||
};
|
||||
// A dynamic line (#1741): `{name}`, `{date}` and `{time}` filled in now.
|
||||
let edit = match (edit, a.opt_str("line")?) {
|
||||
(Edit::AddCustomStamp { page, rect, name, file, author, .. }, Some(t)) if !t.trim().is_empty() => {
|
||||
let line = Some(self.session().stamp_line(t, &author));
|
||||
Edit::AddCustomStamp { page, rect, name, file, author, line }
|
||||
}
|
||||
(edit, _) => edit,
|
||||
};
|
||||
self.apply(a, edit)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ fn to_view(r: [f64; 4], ph: f64) -> [f64; 4] {
|
||||
[r[0], ph - r[3], r[2], ph - r[1]]
|
||||
}
|
||||
|
||||
fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
|
||||
pub(crate) fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
|
||||
a.get(key)
|
||||
.map(|v| {
|
||||
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
|
||||
@@ -74,6 +74,7 @@ impl Automation {
|
||||
"text_extract" => self.text_extract(&a)?,
|
||||
"text_word_count" => self.text_word_count(&a)?,
|
||||
"text_find" => self.text_find(&a)?,
|
||||
"text_replace" => self.text_replace(&a)?,
|
||||
"page_rotate" => {
|
||||
let degrees = a.int("degrees")?;
|
||||
if degrees % 90 != 0 {
|
||||
@@ -700,7 +701,7 @@ impl Automation {
|
||||
summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?)
|
||||
}
|
||||
"doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?,
|
||||
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
|
||||
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?,
|
||||
"doc_backdrops" => self.backdrops(&a)?,
|
||||
"doc_unprotect" => {
|
||||
let mut out = self.apply(&a, Edit::RemoveProtection)?;
|
||||
@@ -709,11 +710,8 @@ impl Automation {
|
||||
}
|
||||
"form_fields" => self.form_fields(&a)?,
|
||||
"form_fill" => self.form_fill(&a)?,
|
||||
"form_set_image" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })?
|
||||
}
|
||||
"form_focus" => self.form_focus(&a)?,
|
||||
"form_set_image" => self.form_set_image(&a)?,
|
||||
"form_reset" => self.form_reset(&a)?,
|
||||
"form_add_field" => self.form_add_field(&a)?,
|
||||
"form_add_field_table" => self.form_add_field_table(&a)?,
|
||||
@@ -729,6 +727,7 @@ impl Automation {
|
||||
"doc_hidden_info" => self.doc_hidden_info(&a)?,
|
||||
"printers" => self.printers()?,
|
||||
"printer_options" => self.printer_options(&a)?,
|
||||
"printer_papers" => self.printer_papers(&a)?,
|
||||
"link_list" => self.link_list(&a)?,
|
||||
"link_open" => self.link_open(&a)?,
|
||||
"link_add" => self.link_add(&a)?,
|
||||
@@ -767,6 +766,49 @@ impl Automation {
|
||||
let edit = Edit::DeleteAttachment { key: a.str("name")?.into() };
|
||||
self.apply(&a, edit)?
|
||||
}
|
||||
"doc_convert_colours" => self.convert_colours(&a)?,
|
||||
"attachment_edit" => {
|
||||
let key = a.opt_str("key")?;
|
||||
let name = a.opt_str("name")?;
|
||||
if key.is_some() == name.is_some() {
|
||||
return Err(ToolError::InvalidArgs("provide exactly one of key or name".into()));
|
||||
}
|
||||
let keys: Vec<String> = self
|
||||
.doc(&a)?
|
||||
.info
|
||||
.attachments
|
||||
.iter()
|
||||
.filter_map(|attachment| match &attachment.source {
|
||||
pdfcraft_render::AttachmentSource::Document { key: attachment_key }
|
||||
if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) =>
|
||||
{
|
||||
Some(attachment_key.clone())
|
||||
}
|
||||
pdfcraft_render::AttachmentSource::Document { .. } => None,
|
||||
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let key = match keys.as_slice() {
|
||||
[key] => key.clone(),
|
||||
[] if key.is_some() => {
|
||||
return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default())));
|
||||
}
|
||||
[] => {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"there is no document attachment named {:?}; use a name from doc_info",
|
||||
name.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"more than one document attachment is named {:?}; provide its key from doc_info",
|
||||
name.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) };
|
||||
self.apply(&a, edit)?
|
||||
}
|
||||
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
|
||||
"doc_audit_redactions" => self.doc_audit_redactions(&a)?,
|
||||
"fill_sign_add" => self.fill_sign_add(&a)?,
|
||||
@@ -793,6 +835,7 @@ impl Automation {
|
||||
"accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?,
|
||||
"ocr_recognize" => self.ocr_recognize(&a)?,
|
||||
"js_run" => self.js_run(&a)?,
|
||||
"js_event" => self.js_event(&a)?,
|
||||
"js_document_scripts" => self.js_document_scripts(&a)?,
|
||||
"js_set_document_script" => self.js_set_document_script(&a)?,
|
||||
"js_enabled" => self.js_enabled(&a)?,
|
||||
@@ -873,6 +916,7 @@ impl Automation {
|
||||
"sign_trust" => self.sign_trust(&a)?,
|
||||
"comment_mark" => self.comment_mark(&a)?,
|
||||
"comment_lock" => self.comment_lock(&a)?,
|
||||
"comment_popup" => self.comment_popup(&a)?,
|
||||
"comments_hide" => self.comments_hide(&a)?,
|
||||
"layer_set" => self.layer_set(&a)?,
|
||||
"view_reading_colors" => self.view_reading_colors(&a)?,
|
||||
|
||||
@@ -104,12 +104,27 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn doc_open(&mut self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let requested = a.str("path")?;
|
||||
// `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file under
|
||||
// --root, and where it opens. A path that names a file is never split.
|
||||
let parameters = pdfcraft_engine::open_parameters::split(requested, |p| self.resolve(p, false).is_ok_and(|p| p.is_file()));
|
||||
let (requested, destination) = match ¶meters {
|
||||
Some((file, destination)) => (file.as_str(), Some(destination.clone())),
|
||||
None => (requested, None),
|
||||
};
|
||||
let path = self.resolve(requested, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
|
||||
Ok(summary(doc))
|
||||
let mut value = summary(doc);
|
||||
if let Some(destination) = destination {
|
||||
let (applied, destination, warning) = crate::links::destination_report(doc, destination);
|
||||
value["destination_applied"] = json!(applied);
|
||||
value["destination"] = destination;
|
||||
value["warning"] = json!(warning);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_close(&mut self, a: &Args) -> Result<Value> {
|
||||
@@ -153,11 +168,19 @@ impl Automation {
|
||||
if flatten_fill_sign {
|
||||
self.apply(a, Edit::FlattenFillSign)?;
|
||||
}
|
||||
// The document's Will Save actions run first: what they change is saved (#269).
|
||||
let scripts = self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave);
|
||||
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?;
|
||||
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
|
||||
// redaction), is rewritten in full even when an incremental save was asked for.
|
||||
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
|
||||
self.publish_save(id, &target, bytes, incremental, write)
|
||||
let mut out = self.publish_save(id, &target, bytes, incremental, write)?;
|
||||
if !scripts.is_empty()
|
||||
&& let Some(o) = out.as_object_mut()
|
||||
{
|
||||
o.insert("will_save".into(), crate::a11y::form_tools::js_output_json(&scripts));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Validate the prepared bytes before publishing them or marking the document saved.
|
||||
@@ -360,6 +383,7 @@ impl Automation {
|
||||
("discard_thumbnails", &mut settings.discard_thumbnails),
|
||||
("discard_alternate_images", &mut settings.discard_alternate_images),
|
||||
("discard_tags", &mut settings.discard_tags),
|
||||
("discard_search_index", &mut settings.discard_search_index),
|
||||
("discard_print_settings", &mut settings.discard_print_settings),
|
||||
("discard_form_actions", &mut settings.discard_form_actions),
|
||||
("flate_unencoded", &mut settings.flate_unencoded),
|
||||
@@ -367,6 +391,7 @@ impl Automation {
|
||||
("lzw_to_flate", &mut settings.lzw_to_flate),
|
||||
("remove_invalid_links", &mut settings.remove_invalid_links),
|
||||
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
|
||||
("discard_external_refs", &mut settings.discard_external_refs),
|
||||
] {
|
||||
if let Some(b) = a.opt_bool(key)? {
|
||||
*flag = b;
|
||||
@@ -471,6 +496,7 @@ impl Automation {
|
||||
"image_bytes_after": o.image_bytes_after,
|
||||
"thumbnails": o.thumbnails,
|
||||
"alternate_images": o.alternate_images,
|
||||
"search_indexes": o.search_indexes,
|
||||
"tags_removed": o.tags_removed,
|
||||
"print_settings": o.print_settings,
|
||||
"form_actions": o.form_actions,
|
||||
@@ -479,6 +505,8 @@ impl Automation {
|
||||
"invalid_links": o.invalid_links,
|
||||
"invalid_bookmarks": o.invalid_bookmarks,
|
||||
"unreferenced_dests": o.unreferenced_dests,
|
||||
"external_links": o.external_links,
|
||||
"external_bookmarks": o.external_bookmarks,
|
||||
"unused_xobjects": o.unused_xobjects,
|
||||
"merged_objects": r.merged,
|
||||
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
|
||||
@@ -626,11 +654,33 @@ impl Automation {
|
||||
replace,
|
||||
file: self.mark_file(a)?,
|
||||
},
|
||||
"doc_printer_marks" => {
|
||||
use pdfcraft_engine::PrinterMarks;
|
||||
let flag = |k: &str, default: bool| -> Result<bool> { Ok(a.opt_bool(k)?.unwrap_or(default)) };
|
||||
let (y, m, d) = self.session.today();
|
||||
let settings = PrinterMarks {
|
||||
trim: flag("trim", true)?,
|
||||
bleed: flag("bleed", true)?,
|
||||
registration: flag("registration", true)?,
|
||||
color_bars: flag("color_bars", true)?,
|
||||
page_info: flag("page_info", true)?,
|
||||
line_weight: a.opt_num("line_weight")?.unwrap_or(0.25),
|
||||
make_room: match a.opt_num("make_room")? {
|
||||
Some(r) if r < 0.0 => None,
|
||||
Some(r) => Some(r),
|
||||
None => Some(36.0),
|
||||
},
|
||||
label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()),
|
||||
date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")),
|
||||
};
|
||||
Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) }
|
||||
}
|
||||
_ => {
|
||||
let kind = match a.str("kind")? {
|
||||
"header_footer" => MarkKind::HeaderFooter,
|
||||
"watermark" => MarkKind::Watermark,
|
||||
"background" => MarkKind::Background,
|
||||
"printer_marks" => MarkKind::PrinterMarks,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))),
|
||||
};
|
||||
Edit::RemoveMarks { kind }
|
||||
|
||||
@@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option<Value> {
|
||||
match (&f.actions.calculate, &f.actions.scripts.calculate) {
|
||||
(Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
|
||||
(Calculate::Notation(n), _) => Some(json!({ "notation": n })),
|
||||
(Calculate::None, Some(js)) => Some(json!({ "script": js })),
|
||||
(Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })),
|
||||
(Calculate::None, None) => None,
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,24 @@ fn kind_name(k: FormFieldKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON number as the radio option it names: its integer spelling when it is a whole number
|
||||
/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which
|
||||
/// names only an option exported as exactly that text. A whole number at or past 2^53, where a
|
||||
/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option
|
||||
/// by accident.
|
||||
fn number_option(n: &serde_json::Number) -> String {
|
||||
// Integers as parsed (u64, i64) print exactly.
|
||||
if n.is_u64() || n.is_i64() {
|
||||
return n.to_string();
|
||||
}
|
||||
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
|
||||
match n.as_f64() {
|
||||
// Finite, whole and below 2^53 in magnitude, so the cast is exact and in range.
|
||||
Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(),
|
||||
_ => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…).
|
||||
fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
|
||||
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
|
||||
@@ -224,7 +242,24 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
|
||||
FieldValue::Widget(i)
|
||||
}
|
||||
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
|
||||
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())),
|
||||
// An option as `form_fields` lists it (an export value) names its button's on state. The
|
||||
// export values are matched first and the on-state names after them
|
||||
// (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first
|
||||
// button, not the second one's state. A value matching neither is refused when written,
|
||||
// with the options listed.
|
||||
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())),
|
||||
// A number is the same option written without quotes (`1` for the export value `1`).
|
||||
// Below 2^53 it is compared as a number with the export values first, so `1.0` names the button
|
||||
// exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is
|
||||
// `/1`; then as text, as a string would be.
|
||||
(FormFieldKind::Radio, Value::Number(n)) => {
|
||||
if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) {
|
||||
FieldValue::Radio(Some(state.to_owned()))
|
||||
} else {
|
||||
let s = number_option(n);
|
||||
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
|
||||
}
|
||||
}
|
||||
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
|
||||
a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::<Result<_>>()?,
|
||||
),
|
||||
@@ -237,6 +272,27 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Import an image using an optional exact field ID, preserving the legacy name-only tool.
|
||||
pub(crate) fn form_set_image(&mut self, a: &Args) -> Result<Value> {
|
||||
let name = a.str("field")?.to_owned();
|
||||
let object = a
|
||||
.0
|
||||
.get("field_id")
|
||||
.map(|value| -> Result<pdfcraft_cos::ObjRef> {
|
||||
let key =
|
||||
value.as_u64().filter(|key| *key < 1u64 << 48).ok_or_else(|| bad("field_id must be an integer between 0 and 281474976710655"))?;
|
||||
Ok(pdfcraft_cos::ObjRef::new((key >> 16) as u32, key as u16))
|
||||
})
|
||||
.transpose()?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let image = std::sync::Arc::new(crate::paths::read_input(&path)?);
|
||||
let edit = match object {
|
||||
Some(object) => Edit::SetFieldImageTarget { name, object, image },
|
||||
None => Edit::SetFieldImage { name, image },
|
||||
};
|
||||
self.apply(a, edit)
|
||||
}
|
||||
|
||||
pub(crate) fn form_fields(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let out: Vec<Value> = doc
|
||||
@@ -251,6 +307,7 @@ impl Automation {
|
||||
});
|
||||
let mut v = json!({
|
||||
"name": f.name,
|
||||
"field_id": pdfcraft_engine::js::field_object_id(f.obj),
|
||||
"type": kind_name(f.kind),
|
||||
"value": match f.kind {
|
||||
FormFieldKind::CheckBox => json!(!f.value.is_empty()),
|
||||
@@ -310,8 +367,35 @@ impl Automation {
|
||||
json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::<Vec<_>>()),
|
||||
);
|
||||
}
|
||||
// A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3):
|
||||
// which one is ticked, and the names form_fill takes to tick each. One option
|
||||
// per distinct on state: kids sharing a state tick together (EXAMPLE 2), so
|
||||
// their `/Opt` exports are not separate choices. Exports name the options
|
||||
// unless two states share one (Table 230 allows it) or an export would tick
|
||||
// another state (form_fill reads a name as an on state first); then the
|
||||
// states do.
|
||||
FormFieldKind::CheckBox => {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect();
|
||||
if states.len() > 1 {
|
||||
let mut exports = std::collections::HashSet::new();
|
||||
let by_export = states.iter().all(|s| {
|
||||
let e = f.export_for_state(s);
|
||||
exports.insert(e) && f.state_for(e) == Some(*s)
|
||||
});
|
||||
let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() };
|
||||
// The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3
|
||||
// makes `/AS` decide where it and `/V` differ.
|
||||
let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref());
|
||||
o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::<Vec<_>>()));
|
||||
o.insert("checked".into(), json!(on.map(name)));
|
||||
}
|
||||
}
|
||||
FormFieldKind::Combo | FormFieldKind::List => {
|
||||
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
|
||||
// The selected options as 1-based positions in `options`: two options can share
|
||||
// an export value, and `value` alone does not tell them apart.
|
||||
o.insert("selected".into(), json!(f.selected.iter().map(|i| i.saturating_add(1)).collect::<Vec<_>>()));
|
||||
o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT)));
|
||||
o.insert("editable".into(), json!(f.has(field_flags::EDIT)));
|
||||
}
|
||||
@@ -583,6 +667,7 @@ impl Automation {
|
||||
Ok((wi, caption.to_owned()))
|
||||
})
|
||||
.transpose()?,
|
||||
caption_all: None,
|
||||
read_only: a.opt_bool("read_only")?,
|
||||
required: a.opt_bool("required")?,
|
||||
multiline: a.opt_bool("multiline")?,
|
||||
|
||||
@@ -256,7 +256,7 @@ pub(crate) fn security(d: &Document) -> Value {
|
||||
"method": s.method,
|
||||
"pending": s.pending,
|
||||
"printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" },
|
||||
"modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(),
|
||||
"modify": p.modify(), "assemble": p.assemble(), "extract_pages": p.extract_pages(), "copy": p.copy(), "annotate": p.annotate(),
|
||||
"fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(),
|
||||
})
|
||||
}
|
||||
@@ -334,6 +334,12 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
"target": link_target(i, &l.target),
|
||||
})).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
|
||||
// The Layers panel's rows, the default configuration's /Order (ISO 32000-2 Table 99): a
|
||||
// layer by its 1-based position in `layers`, or a label; `depth` 0 at the top.
|
||||
"layer_order": i.layer_order.iter().map(|r| match &r.item {
|
||||
pdfcraft_render::LayerItem::Layer(n) => json!({ "depth": r.depth, "layer": n.saturating_add(1), "name": i.layers.get(*n).map(|l| l.name.as_str()) }),
|
||||
pdfcraft_render::LayerItem::Label(s) => json!({ "depth": r.depth, "label": s }),
|
||||
}).collect::<Vec<_>>(),
|
||||
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
|
||||
// a `/Trans` or `/Dur` appear; `style` is null for a display time alone.
|
||||
"transitions": i.transitions.iter().map(|t| json!({
|
||||
@@ -341,7 +347,12 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
"horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque,
|
||||
"display": t.display,
|
||||
})).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
// A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its
|
||||
// documents are `attachments`; `initial` names the one presented first, null for the cover.
|
||||
"portfolio": i.portfolio.as_ref().map(|p| json!({
|
||||
"initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(),
|
||||
})),
|
||||
// `substitute` names the face standing in for a program the document does not embed, and
|
||||
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
|
||||
// `substitute` with a non-null `substitution` means no face was available at all, so
|
||||
|
||||
@@ -11,6 +11,26 @@ use std::path::Path;
|
||||
use crate::comments::parse_color;
|
||||
use crate::{Args, Automation, Result, ToolError, failed};
|
||||
|
||||
/// Where `destination` puts `doc`, a document just opened from a link or with open parameters:
|
||||
/// whether it applies, the destination as reported, and a warning when it doesn't. A named
|
||||
/// destination is looked up in the document, as the app does.
|
||||
pub(crate) fn destination_report(doc: &pdfcraft_engine::Document, destination: RemoteDestination) -> (bool, Value, Option<&'static str>) {
|
||||
let (name, page, view) = match destination {
|
||||
RemoteDestination::Default => return (true, json!({ "kind": "default" }), None),
|
||||
RemoteDestination::Page { page, view } => (None, page, view),
|
||||
RemoteDestination::Named(name) => match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) {
|
||||
Some((page, view)) => (Some(name), page, view),
|
||||
None => return (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")),
|
||||
},
|
||||
};
|
||||
let mut value = json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) });
|
||||
if let Some(name) = name {
|
||||
value["kind"] = json!("named");
|
||||
value["name"] = json!(name);
|
||||
}
|
||||
if page < doc.info.pages.len() { (true, value, None) } else { (false, value, Some("The PDF opened, but the page destination is out of range.")) }
|
||||
}
|
||||
|
||||
fn bad(m: impl Into<String>) -> ToolError {
|
||||
ToolError::InvalidArgs(m.into())
|
||||
}
|
||||
@@ -77,7 +97,8 @@ impl Automation {
|
||||
|
||||
pub(crate) fn link_list(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect();
|
||||
let viewed: std::collections::HashMap<(usize, usize), &pdfcraft_render::Link> =
|
||||
doc.info.links.iter().map(|t| ((t.page, t.index), t)).collect();
|
||||
let items: Vec<Value> = doc
|
||||
.links()
|
||||
.iter()
|
||||
@@ -85,14 +106,49 @@ impl Automation {
|
||||
// The link's page may be one the viewer could not read (#2660): rect unchanged then.
|
||||
let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32));
|
||||
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
|
||||
let link = viewed.get(&(l.page, l.index));
|
||||
// Where the link is active when that isn't its whole rect: its /QuadPoints
|
||||
// (ISO 32000-2 Table 176), as the bounding box of each quadrilateral.
|
||||
if let Some(link) = link.filter(|k| !k.quads.is_empty()) {
|
||||
let areas: Vec<Value> = link
|
||||
.quads
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (xs, ys) = ([q[0], q[2], q[4], q[6]], [q[1], q[3], q[5], q[7]]);
|
||||
let fold = |v: [f32; 4], f: fn(f32, f32) -> f32, start: f32| v.into_iter().fold(start, f);
|
||||
let box_ = [
|
||||
fold(xs, f32::min, f32::MAX),
|
||||
fold(ys, f32::min, f32::MAX),
|
||||
fold(xs, f32::max, f32::MIN),
|
||||
fold(ys, f32::max, f32::MIN),
|
||||
];
|
||||
json!(crate::view_rect(&doc.info, l.page, box_))
|
||||
})
|
||||
.collect();
|
||||
v["active_areas"] = json!(areas);
|
||||
}
|
||||
// Where an in-document link goes, as the viewer resolves it: named destinations
|
||||
// included, with the position on the page.
|
||||
let target = targets.get(&(l.page, l.index)).and_then(|t| match t {
|
||||
let target = link.and_then(|t| match &t.target {
|
||||
LinkTarget::Page(page, view) => Some((*page, view)),
|
||||
_ => None,
|
||||
});
|
||||
match (&l.action, target) {
|
||||
(LinkAction::Uri(u), _) => v["url"] = json!(u),
|
||||
(LinkAction::Uri(u), _) => {
|
||||
v["url"] = json!(u);
|
||||
// A relative address, resolved against the catalog's /URI /Base
|
||||
// (ISO 32000-2 Table 211): what the viewer offers to open. That is the
|
||||
// address inspection kept, which stays raw once its text budget is spent;
|
||||
// resolve afresh only for a link inspection did not list.
|
||||
let opened = match link.map(|t| &t.target) {
|
||||
Some(LinkTarget::Uri(kept)) => Some(kept.clone()),
|
||||
Some(_) => None,
|
||||
None => pdfcraft_render::resolve_uri(doc.info.uri_base.as_deref(), u),
|
||||
};
|
||||
if let Some(opened) = opened.filter(|o| o != u) {
|
||||
v["resolved_url"] = json!(opened);
|
||||
}
|
||||
}
|
||||
(action, Some((page, view))) => {
|
||||
v["to_page"] = json!(page + 1);
|
||||
if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) {
|
||||
@@ -186,20 +242,7 @@ impl Automation {
|
||||
let id =
|
||||
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
|
||||
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
|
||||
let (destination_applied, destination_value, warning) = match destination {
|
||||
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
|
||||
RemoteDestination::Named(name) => {
|
||||
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
|
||||
}
|
||||
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
|
||||
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
|
||||
}
|
||||
RemoteDestination::Page { page, view } => (
|
||||
false,
|
||||
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
|
||||
Some("The PDF opened, but the link's page destination is out of range."),
|
||||
),
|
||||
};
|
||||
let (destination_applied, destination_value, warning) = destination_report(opened, destination);
|
||||
let mut display_path = path.to_string_lossy().into_owned();
|
||||
if let Some(root) = &self.root
|
||||
&& let Ok(relative) = path.strip_prefix(root)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::dispatch::Content;
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, one_based};
|
||||
use super::json::{Args, one_based, summary};
|
||||
use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools};
|
||||
|
||||
/// Default and maximum resolution for `page_render`.
|
||||
@@ -356,6 +356,103 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Find and replace (Edit text ▸ Replace): every match (or the first) rewritten inside its
|
||||
/// own paragraph through the same engine plan as the window's Replace all, one undo step.
|
||||
/// The page text is searched too (as `text_find` does), so matches no paragraph holds are
|
||||
/// counted as not editable and the counts add up to what `text_find` reports.
|
||||
pub(crate) fn text_replace(&mut self, a: &Args) -> Result<Value> {
|
||||
self.text_replace_with(a, &mut extract_parallel)
|
||||
}
|
||||
|
||||
pub(crate) fn text_replace_with(
|
||||
&mut self,
|
||||
a: &Args,
|
||||
extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts,
|
||||
) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, n) = (doc.id, doc.info.pages.len());
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let query = pdfcraft_engine::find_replace::Replace {
|
||||
find: a.str("find")?.to_owned(),
|
||||
with: a.str("replace")?.to_owned(),
|
||||
case_sensitive: a.opt_bool("case_sensitive")?.unwrap_or(false),
|
||||
whole_words: a.opt_bool("whole_words")?.unwrap_or(false),
|
||||
};
|
||||
let all = a.opt_bool("all")?.unwrap_or(true);
|
||||
let mut pages = pages;
|
||||
pages.sort_unstable();
|
||||
pages.dedup();
|
||||
// Pages whose text the find bar couldn't read, reported with the plan, not dropped.
|
||||
let mut unreadable: Vec<(usize, String)> = Vec::new();
|
||||
let (which, found) = if all {
|
||||
let mut found = std::collections::BTreeMap::new();
|
||||
for (p, text) in self.page_text_results(id, &pages, &mut *extract)? {
|
||||
match text {
|
||||
Ok(text) => {
|
||||
found.insert(p, text.find_opts(&query.find, query.case_sensitive, query.whole_words).len());
|
||||
}
|
||||
Err(e) => unreadable.push((p, e)),
|
||||
}
|
||||
}
|
||||
unreadable.sort();
|
||||
(Some(pdfcraft_engine::find_replace::Which::All), Some(found))
|
||||
} else {
|
||||
// The first match the find bar shows, and that one only: pages are read in order until
|
||||
// one holds a match, and a page that can't be read stops the search there.
|
||||
let mut texts = Vec::new();
|
||||
let (mut start, mut batch) = (0, FIND_FIRST_BATCH.max(1));
|
||||
'search: while start < pages.len() {
|
||||
let end = pages.len().min(start.saturating_add(batch));
|
||||
let mut results: std::collections::HashMap<usize, _> =
|
||||
self.page_text_results(id, &pages[start..end], &mut *extract)?.into_iter().collect();
|
||||
for p in &pages[start..end] {
|
||||
match results.remove(p) {
|
||||
Some(Ok(text)) if text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).is_empty() => {}
|
||||
Some(Ok(text)) => {
|
||||
texts.push((*p, text));
|
||||
break 'search;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
unreadable.push((*p, e));
|
||||
break 'search;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
batch = batch.saturating_mul(2).min(FIND_MAX_BATCH);
|
||||
}
|
||||
(pdfcraft_engine::find_replace::Which::first_in(texts.iter().map(|(p, t)| (*p, t.as_ref())), &query), None)
|
||||
};
|
||||
let mut plan = match which {
|
||||
Some(which) => self.doc(a)?.plan_replace(&pages, &query, which, found.as_ref()).map_err(failed)?,
|
||||
None => {
|
||||
// Nothing to replace: still the permission and query checks of a plan.
|
||||
self.doc(a)?.plan_replace(&[], &query, pdfcraft_engine::find_replace::Which::All, None).map_err(failed)?
|
||||
}
|
||||
};
|
||||
for (p, e) in &unreadable {
|
||||
plan.unreadable_page(*p, e);
|
||||
}
|
||||
let mut out = match plan.edit.clone() {
|
||||
Some(edit) => self.apply(a, edit)?,
|
||||
None => summary(self.doc(a)?),
|
||||
};
|
||||
let skipped: serde_json::Map<String, Value> = plan.skipped.iter().map(|(why, k)| (why.key().to_owned(), json!(k))).collect();
|
||||
out["replaced"] = json!(plan.replaced);
|
||||
out["changed"] = json!(plan.edit.is_some());
|
||||
out["pages"] = json!(plan.pages.iter().map(|p| p + 1).collect::<Vec<_>>());
|
||||
out["skipped"] = json!(plan.skipped_total());
|
||||
out["skipped_by"] = Value::Object(skipped);
|
||||
if !plan.refusals.is_empty() {
|
||||
out["refusals"] = json!(plan.refusals);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn command_list(&self, a: &Args) -> Result<Value> {
|
||||
let active = match a.opt_int("doc")? {
|
||||
Some(_) => Some(self.doc(a)?.id),
|
||||
@@ -412,8 +509,11 @@ pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize)
|
||||
fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str {
|
||||
use pdfcraft_render::RenderWarning;
|
||||
match warning {
|
||||
RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
|
||||
RenderWarning::ContentTruncated => {
|
||||
"content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"
|
||||
}
|
||||
RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
|
||||
RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all",
|
||||
RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print
|
||||
//! dialog options.
|
||||
|
||||
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
|
||||
use pdfcraft_engine::print::{self, Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
|
||||
@@ -23,45 +23,104 @@ fn duplex(value: &str, cut_stack: bool) -> Result<Option<spool::Duplex>> {
|
||||
}
|
||||
|
||||
/// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet
|
||||
/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`).
|
||||
fn job_duplex(value: &str, layout: &Layout) -> Result<Option<spool::Duplex>> {
|
||||
/// decides two-sided itself (#2707) as far as the printer's driver takes it, as the Print dialog
|
||||
/// does (`print::booklet_job_duplex`). `two_sided` says whether it does; it is only asked for a
|
||||
/// booklet's both sides, the one job it changes.
|
||||
fn job_duplex(value: &str, layout: &Layout, two_sided: impl FnOnce() -> Option<bool>) -> Result<Option<spool::Duplex>> {
|
||||
let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?;
|
||||
Ok(match layout {
|
||||
Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)),
|
||||
Layout::Booklet { subset: BookletSubset::BothSides, .. } => print::booklet_job_duplex(BookletSubset::BothSides, two_sided()),
|
||||
Layout::Booklet { subset, .. } => print::booklet_job_duplex(*subset, None),
|
||||
_ => chosen,
|
||||
})
|
||||
}
|
||||
|
||||
/// `printer` (a name, or "default" for the default printer) in `printers`; `None` when the list
|
||||
/// has no such printer.
|
||||
fn printer_in(printers: Vec<spool::Printer>, printer: &str) -> Option<spool::Printer> {
|
||||
printers.into_iter().find(|p| if printer == "default" { p.default } else { p.name == printer })
|
||||
}
|
||||
|
||||
/// An invalid-arguments error saying `m`.
|
||||
fn bad(m: impl Into<String>) -> ToolError {
|
||||
ToolError::InvalidArgs(m.into())
|
||||
}
|
||||
|
||||
/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS`
|
||||
/// index and size; US Letter when none is given.
|
||||
fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
|
||||
let mut papers = PAPERS.iter().enumerate();
|
||||
let found = match name {
|
||||
None => papers.next(),
|
||||
Some(p) => papers.find(|(_, (n, _))| {
|
||||
n.eq_ignore_ascii_case(p)
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
|
||||
}),
|
||||
/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt", "300pt x 500pt", "Custom.300x500".
|
||||
fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> {
|
||||
let s = s.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim();
|
||||
let (w_str, h_str) = s.split_once(['x', 'X', '*'])?;
|
||||
let parse_dim = |d: &str| {
|
||||
let d = d.trim().trim_end_matches("points").trim_end_matches("pt").trim();
|
||||
d.parse::<f64>().ok()
|
||||
};
|
||||
found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"unknown paper {:?}; choose one of {}",
|
||||
name.unwrap_or_default(),
|
||||
let w = parse_dim(w_str)?;
|
||||
let h = parse_dim(h_str)?;
|
||||
if w.is_finite() && h.is_finite() && w >= 72.0 && h >= 72.0 && w <= 14400.0 && h <= 14400.0 { Some((w, h)) } else { None }
|
||||
}
|
||||
|
||||
/// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions.
|
||||
/// Returns `((width, height), media_name)`.
|
||||
fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), Option<String>)> {
|
||||
if let Some(p) = name {
|
||||
// 1. Match standard PAPERS presets.
|
||||
for (index, (n, size)) in PAPERS.iter().enumerate() {
|
||||
if n.eq_ignore_ascii_case(p)
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|label| label.eq_ignore_ascii_case(p))
|
||||
{
|
||||
let media = print::cups_media(index).map(String::from);
|
||||
return Ok((*size, media));
|
||||
}
|
||||
}
|
||||
// 2. Match detected driver papers for the given printer.
|
||||
if let Some(printer_name) = printer {
|
||||
let driver_papers = spool::printer_papers(printer_name);
|
||||
if let Some((name, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) {
|
||||
return Ok((*size, Some(print::paper_media(Some(name), *size))));
|
||||
}
|
||||
}
|
||||
// 3. Match custom dimensions ("<w>x<h>").
|
||||
if let Some(size) = parse_custom_dimensions(p) {
|
||||
return Ok((size, Some(print::paper_media(None, size))));
|
||||
}
|
||||
// 4. Explicit units request the same stock as point dimensions.
|
||||
if let Some(size) = paper_size(p) {
|
||||
return Ok((size, Some(print::paper_media(None, size))));
|
||||
}
|
||||
return Err(bad(format!(
|
||||
"unknown paper {p:?}; choose one of {}, a paper from printer_papers, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)",
|
||||
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
// When paper is omitted and a printer is given, use the printer's configured default paper if readable.
|
||||
if let Some(printer_name) = printer
|
||||
&& let Some((name, size)) = spool::printer_default_paper(printer_name)
|
||||
{
|
||||
return Ok((size, Some(print::paper_media(Some(&name), size))));
|
||||
}
|
||||
|
||||
let (name, size) = PAPERS[0];
|
||||
let media = print::paper_media(Some(name), size);
|
||||
Ok((size, Some(media)))
|
||||
}
|
||||
|
||||
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
|
||||
/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default
|
||||
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752).
|
||||
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: usize) -> Result<spool::Job> {
|
||||
/// (as `media` after the driver's `options`) rather than the queue's default paper, which a job
|
||||
/// naming none got (A4 under Letter sheets on the queue in #3752), and tells CUPS which way the
|
||||
/// laid-out `sheets` run, as the Print dialog does (#2713): an orientation the caller chose in
|
||||
/// `options` stands. It asks the driver for grayscale only when the pages aren't grey pictures
|
||||
/// already ([`print::driver_needs_grayscale`]).
|
||||
fn spool_job(
|
||||
a: &Args,
|
||||
printer: &str,
|
||||
title: String,
|
||||
duplex: Option<spool::Duplex>,
|
||||
media: Option<&str>,
|
||||
raster: Option<print::Raster>,
|
||||
sheets: &[print::Sheet],
|
||||
) -> Result<spool::Job> {
|
||||
let options = match a.get("options") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Object(m)) => m
|
||||
@@ -79,13 +138,58 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duple
|
||||
collate: a.opt_bool("collate")?.unwrap_or(true),
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
grayscale: print::driver_needs_grayscale(a.opt_bool("grayscale")?.unwrap_or(false), raster),
|
||||
// Set when the copy sent is made grey (`spooler_copy`, #5806).
|
||||
grey_pages: false,
|
||||
title,
|
||||
options: options.into_iter().chain(print::media_option(paper)).collect(),
|
||||
options: {
|
||||
let mut options: Vec<(String, String)> = options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect();
|
||||
print::add_cups_orientation(&mut options, sheets);
|
||||
options
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Print production ▸ Convert colours: every colour on the pages in gray, rgb or cmyk.
|
||||
pub(crate) fn convert_colours(&mut self, a: &Args) -> Result<Value> {
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let target = match a.opt_str("to")?.unwrap_or("gray") {
|
||||
"gray" | "grayscale" => pdfcraft_engine::ColourTarget::Gray,
|
||||
"rgb" => pdfcraft_engine::ColourTarget::Rgb,
|
||||
"cmyk" => pdfcraft_engine::ColourTarget::Cmyk,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown target {other:?} (gray, rgb, cmyk)"))),
|
||||
};
|
||||
let settings = pdfcraft_engine::ConvertColours {
|
||||
target,
|
||||
preserve_black: a.opt_bool("preserve_black")?.unwrap_or(true),
|
||||
images: a.opt_bool("images")?.unwrap_or(true),
|
||||
annotations: a.opt_bool("annotations")?.unwrap_or(false),
|
||||
};
|
||||
let id = self.doc(a)?.id;
|
||||
let found = self.session.count_colours(id, &pages, &settings);
|
||||
if found.colours == 0 && found.images == 0 {
|
||||
return Ok(json!({ "colours": 0, "images": 0, "images_left": found.images_left, "pages": 0, "loses_conformance": [] }));
|
||||
}
|
||||
// PDF/X has no declaration of its own here: it always carries an output intent.
|
||||
let declared = self.doc(a)?.standards();
|
||||
let pdfa = declared.pdfa.map(|(part, level)| format!("PDF/A-{part}{}", level.to_lowercase()));
|
||||
let loses: Vec<String> = pdfa.into_iter().chain(declared.output_intents.iter().map(|i| format!("output intent {i}"))).collect();
|
||||
let r = self.apply(a, pdfcraft_engine::Edit::ConvertColours { pages, settings })?;
|
||||
Ok(json!({
|
||||
"colours": found.colours,
|
||||
"images": found.images,
|
||||
"images_left": found.images_left,
|
||||
"pages": found.pages,
|
||||
"loses_conformance": loses,
|
||||
"undo": r["undo"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// `printers`: the system's printers, the default marked, and each one's two-sided support
|
||||
/// where the spooler reports it.
|
||||
pub(crate) fn printers(&self) -> Result<Value> {
|
||||
@@ -114,6 +218,21 @@ impl Automation {
|
||||
Ok(json!({ "printer": printer, "count": list.len(), "options": list }))
|
||||
}
|
||||
|
||||
/// `printer_papers`: the paper sizes (names and dimensions in points) and default paper
|
||||
/// reported by the printer driver.
|
||||
pub(crate) fn printer_papers(&self, a: &Args) -> Result<Value> {
|
||||
let printer = a.str("printer")?;
|
||||
let resolved = if printer == "default" {
|
||||
spool::printers().into_iter().find(|p| p.default).map(|p| p.name).unwrap_or_else(|| printer.to_string())
|
||||
} else {
|
||||
printer.to_string()
|
||||
};
|
||||
let (papers, default_paper) = spool::printer_paper_info(&resolved);
|
||||
let list: Vec<Value> = papers.into_iter().map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height })).collect();
|
||||
let default_val = default_paper.map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height }));
|
||||
Ok(json!({ "printer": printer, "count": list.len(), "papers": list, "default": default_val }))
|
||||
}
|
||||
|
||||
/// `doc_print`: lay the document out with the Print dialog's options, then save the
|
||||
/// print-ready PDF to `path` or send it to `printer` with the chosen paper.
|
||||
pub(crate) fn doc_print(&mut self, a: &Args) -> Result<Value> {
|
||||
@@ -129,9 +248,6 @@ impl Automation {
|
||||
let pages = print::select_pages(count, a.opt_str("pages")?, &labels, subset, a.opt_bool("reverse")?.unwrap_or(false))
|
||||
.map_err(|e| bad(e.to_string()))?;
|
||||
let layout = match a.opt_str("layout")?.unwrap_or("fit") {
|
||||
"fit" => Layout::Size(SizeMode::Fit),
|
||||
"actual" => Layout::Size(SizeMode::Actual),
|
||||
"shrink" => Layout::Size(SizeMode::Shrink),
|
||||
"custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)),
|
||||
"multiple" => {
|
||||
// Custom pages per sheet: `cols` across by `rows` down, both or neither.
|
||||
@@ -176,15 +292,24 @@ impl Automation {
|
||||
s => return Err(bad(format!("unknown booklet_subset {s:?}"))),
|
||||
},
|
||||
binding: if a.opt_str("binding")? == Some("right") { Binding::Right } else { Binding::Left },
|
||||
creep: a.opt_num("creep")?.unwrap_or(0.0),
|
||||
},
|
||||
"poster" => Layout::Poster {
|
||||
scale: a.opt_num("scale")?.unwrap_or(200.0),
|
||||
overlap: a.opt_num("overlap")?.unwrap_or(18.0),
|
||||
cut_marks: a.opt_bool("cut_marks")?.unwrap_or(true),
|
||||
},
|
||||
l => return Err(bad(format!("unknown layout {l:?}"))),
|
||||
l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?),
|
||||
};
|
||||
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
|
||||
let printer = a.opt_str("printer")?;
|
||||
// The printer whose driver a booklet's both sides were decided for. The job goes to it by
|
||||
// name, so "default" can't become another printer while the document is rendered. The
|
||||
// queues are listed without asking every driver (#2192); only this one is asked.
|
||||
let mut asked = None;
|
||||
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout, || {
|
||||
asked = printer.and_then(|p| printer_in(spool::printer_queues(), p));
|
||||
spool::two_sided(&asked.as_ref()?.name)
|
||||
})?;
|
||||
if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) {
|
||||
return Err(bad("cols and rows need layout multiple"));
|
||||
}
|
||||
@@ -197,14 +322,20 @@ impl Automation {
|
||||
"landscape" => Orientation::Landscape,
|
||||
o => return Err(bad(format!("unknown orientation {o:?}"))),
|
||||
};
|
||||
let content = match a.opt_str("comments_forms")?.unwrap_or("document-and-markups") {
|
||||
"document" => Content::Document,
|
||||
"document-and-markups" => Content::DocumentAndMarkups,
|
||||
"document-and-stamps" => Content::DocumentAndStamps,
|
||||
"form-fields-only" => Content::FormFieldsOnly,
|
||||
c => return Err(bad(format!("unknown comments_forms {c:?}"))),
|
||||
};
|
||||
let (paper_index, paper) = paper_named(a.opt_str("paper")?)?;
|
||||
let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups");
|
||||
let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?;
|
||||
// Use the same queue for duplex detection, paper discovery and submission, even if the
|
||||
// system default changes while the document is rendered.
|
||||
let resolved_printer = printer.map(|p| {
|
||||
if let Some(asked) = &asked {
|
||||
asked.name.clone()
|
||||
} else if p == "default" {
|
||||
spool::printer_queues().into_iter().find(|pr| pr.default).map(|pr| pr.name).unwrap_or_else(|| p.to_string())
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
});
|
||||
let (paper, media) = resolve_paper(a.opt_str("paper")?, resolved_printer.as_deref())?;
|
||||
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
|
||||
// `grayscale` is also asked for.
|
||||
let raster = if a.opt_bool("as_image")?.unwrap_or(false) {
|
||||
@@ -216,42 +347,117 @@ impl Automation {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() };
|
||||
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
|
||||
let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len();
|
||||
// Print a selected area: points from the top-left of the displayed page, as other tools
|
||||
// take rectangles; the print crate takes it y up.
|
||||
let area = match crate::content::rect_arg(a, "area")? {
|
||||
None => None,
|
||||
Some(r) => {
|
||||
let [page] = pages.as_slice() else { return Err(bad("area prints one page: give pages as a single page")) };
|
||||
let Some(&(_, h)) = sizes.get(*page) else { return Err(bad("area's page is not in the document")) };
|
||||
Some([r[0].min(r[2]), h - r[1].max(r[3]), r[0].max(r[2]), h - r[1].min(r[3])])
|
||||
}
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new(), area };
|
||||
let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?;
|
||||
let sheets = laid_out.len();
|
||||
// Where it goes is checked before the Will Print actions run, so a call that can't print
|
||||
// doesn't change the document.
|
||||
let destination = match (a.opt_str("path")?, printer) {
|
||||
(Some(p), None) => Destination::File(self.resolve(p, true)?),
|
||||
(None, Some(printer)) => {
|
||||
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
|
||||
Destination::Printer(spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?)
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
};
|
||||
// The document's Will Print actions run first: what they change is printed (#269).
|
||||
let scripts = if self.doc(a)?.allows_printing() {
|
||||
self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillPrint)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
|
||||
let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() });
|
||||
match (a.opt_str("path")?, a.opt_str("printer")?) {
|
||||
(Some(p), None) => {
|
||||
let path = self.resolve(p, true)?;
|
||||
if !scripts.is_empty() {
|
||||
out["will_print"] = crate::a11y::form_tools::js_output_json(&scripts);
|
||||
}
|
||||
match destination {
|
||||
Destination::File(path) => {
|
||||
write_atomic(&path, &bytes)?;
|
||||
out["path"] = json!(path.to_string_lossy());
|
||||
}
|
||||
(None, Some(printer)) => {
|
||||
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?;
|
||||
Destination::Printer(mut job) => {
|
||||
// A GDI (v3) driver gets transparent sheets flattened (#6155, #5838, #5940); XPS
|
||||
// (v4) drivers print them as they are.
|
||||
let flatten = job.printer.as_deref().is_some_and(spool::gdi_driver);
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job, flatten);
|
||||
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `doc_print` sends the sheets: a print-ready PDF file, or a printer's queue.
|
||||
enum Destination {
|
||||
File(std::path::PathBuf),
|
||||
Printer(spool::Job),
|
||||
}
|
||||
|
||||
/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200
|
||||
/// inches (PDF's own page limit).
|
||||
fn paper_size(spec: &str) -> Option<(f64, f64)> {
|
||||
let s = spec.trim().to_ascii_lowercase();
|
||||
let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)]
|
||||
.into_iter()
|
||||
.find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?;
|
||||
let (w, h) = body.split_once('x')?;
|
||||
let side = |t: &str| t.trim().parse::<f64>().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v));
|
||||
let (w, h) = (side(w)?, side(h)?);
|
||||
Some((w.min(h), w.max(h)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error.
|
||||
/// #5772: its both sides leave two-sided to a driver that doesn't take the setting, and only
|
||||
/// they look the printer up.
|
||||
#[test]
|
||||
fn a_booklet_decides_two_sided_itself() {
|
||||
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left };
|
||||
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 };
|
||||
let unread = || -> Option<bool> { panic!("the printer's two-sided support is read for nothing") };
|
||||
for asked in ["printer", "off", "long-edge", "short-edge"] {
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(true)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || None).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(false)).ok(), Some(None), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly), unread).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly), unread).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
}
|
||||
assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err());
|
||||
assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it");
|
||||
assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides), || None).is_err());
|
||||
assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit), unread).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it");
|
||||
}
|
||||
|
||||
/// #5772: a booklet's printer is found by name, or as the default for "default", and only
|
||||
/// what the list says about it counts. "default" is found by its name, which the job is sent
|
||||
/// to.
|
||||
#[test]
|
||||
fn a_booklets_printer_comes_from_the_list() {
|
||||
let printer = |name: &str, default, two_sided| spool::Printer { name: name.into(), default, two_sided, description: String::new() };
|
||||
let list = [printer("Epson", false, Some(false)), printer("Laser", true, Some(true)), printer("Network", false, None)];
|
||||
let found = |list: &[spool::Printer], name| printer_in(list.to_vec(), name).map(|p| (p.name, p.two_sided));
|
||||
assert_eq!(found(&list, "Epson"), Some(("Epson".into(), Some(false))));
|
||||
assert_eq!(found(&list, "Laser"), Some(("Laser".into(), Some(true))));
|
||||
assert_eq!(found(&list, "default"), Some(("Laser".into(), Some(true))), "the default printer");
|
||||
assert_eq!(found(&list, "Network"), Some(("Network".into(), None)), "the list doesn't say");
|
||||
assert_eq!(found(&list, "Gone"), None, "no such printer");
|
||||
assert_eq!(found(&list, "epson"), None, "names are exact");
|
||||
let default_one_sided = [printer("Epson", true, Some(false)), printer("Laser", false, Some(true))];
|
||||
assert_eq!(found(&default_one_sided, "default"), Some(("Epson".into(), Some(false))), "the default printer");
|
||||
let without_default = [printer("Epson", false, Some(false))];
|
||||
assert_eq!(found(&without_default, "default"), None);
|
||||
}
|
||||
|
||||
/// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks
|
||||
@@ -267,13 +473,31 @@ mod tests {
|
||||
assert!(duplex("sideways", false).is_err());
|
||||
}
|
||||
|
||||
/// #2713, #3739: `doc_print` to a printer tells CUPS when every sheet runs landscape, after
|
||||
/// the paper, so a landscape sheet is turned onto the portrait paper rather than printed
|
||||
/// unrotated and cut off. Portrait and mixed jobs say nothing, and an orientation passed in
|
||||
/// `options` stands and isn't sent twice.
|
||||
#[test]
|
||||
fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() {
|
||||
let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::<Vec<_>>();
|
||||
let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some("Letter"), None, s).unwrap().options;
|
||||
let media = ("media".to_string(), "Letter".to_string());
|
||||
let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]);
|
||||
let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] };
|
||||
assert_eq!(job(json!({}), &landscape), turned);
|
||||
assert_eq!(job(json!({}), &sheets(&[(612.0, 792.0)])), vec![media.clone()], "portrait sheets");
|
||||
assert_eq!(job(json!({}), &sheets(&[(792.0, 612.0), (612.0, 792.0)])), vec![media.clone()], "a job that mixes them");
|
||||
let chosen = ("orientation-requested".to_string(), "5".to_string());
|
||||
assert_eq!(job(json!({ "options": { "orientation-requested": "5" } }), &landscape), [chosen, media], "the caller's own choice, once");
|
||||
}
|
||||
|
||||
/// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for
|
||||
/// the one the sheets were laid out on (A4 under Letter sheets on the report's queue).
|
||||
#[test]
|
||||
fn printing_to_a_printer_feeds_the_laid_out_paper() {
|
||||
let job = |args: Value, paper_name: Option<&str>| {
|
||||
let (index, _) = paper_named(paper_name).unwrap();
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap()
|
||||
let (_, media) = resolve_paper(paper_name, None).unwrap();
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, media.as_deref(), None, &[]).unwrap()
|
||||
};
|
||||
let media = |m: &str| ("media".to_string(), m.to_string());
|
||||
// US Letter when no paper is asked for, after the driver's own options.
|
||||
@@ -282,14 +506,59 @@ mod tests {
|
||||
assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf"));
|
||||
// Every paper, by its full name, goes out as its own media name.
|
||||
for (i, (name, size)) in PAPERS.iter().enumerate() {
|
||||
assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}");
|
||||
assert_eq!(resolve_paper(Some(name), None).ok(), Some((*size, Some(print::cups_media(i).unwrap().to_string()))), "{name}");
|
||||
assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::<Vec<_>>(), "{name}");
|
||||
}
|
||||
// Short forms find the same paper.
|
||||
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
|
||||
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
|
||||
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err());
|
||||
// Non-preset dimensions and driver papers carry their media selection.
|
||||
assert_eq!(job(json!({}), Some("300x500")).options, [media("Custom.300x500")]);
|
||||
assert_eq!(job(json!({}), Some("300 x 500 pt")).options, [media("Custom.300x500")]);
|
||||
assert_eq!(job(json!({}), Some("300pt x 500pt")).options, [media("Custom.300x500")]);
|
||||
assert_eq!(job(json!({}), Some("300 pt x 500 pt")).options, [media("Custom.300x500")]);
|
||||
assert_eq!(job(json!({}), Some("Custom.300x500")).options, [media("Custom.300x500")]);
|
||||
assert_eq!(job(json!({}), Some("Custom.300x500pt")).options, [media("Custom.300x500")]);
|
||||
|
||||
assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
|
||||
// Every accepted dimension syntax requests the same stock from the queue.
|
||||
for (input, size) in [
|
||||
("8.5x14in", (612.0, 1008.0)),
|
||||
("210x297mm", (210.0 * 72.0 / 25.4, 297.0 * 72.0 / 25.4)),
|
||||
("10x15cm", (10.0 * 72.0 / 2.54, 15.0 * 72.0 / 2.54)),
|
||||
("300x500pt", (300.0, 500.0)),
|
||||
] {
|
||||
let expected = print::paper_media(None, size);
|
||||
let (resolved_size, resolved_media) = resolve_paper(Some(input), None).unwrap();
|
||||
assert!((resolved_size.0 - size.0).abs() < 0.01 && (resolved_size.1 - size.1).abs() < 0.01, "{input}");
|
||||
assert_eq!(resolved_media.as_deref(), Some(expected.as_str()), "{input}");
|
||||
assert_eq!(job(json!({}), Some(input)).options, [media(&expected)], "{input}");
|
||||
}
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter"), None, &[]).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter"), None, &[]).is_err());
|
||||
}
|
||||
|
||||
/// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for
|
||||
/// grayscale, which Microsoft Print to PDF keeps no setting for and the job was refused.
|
||||
#[test]
|
||||
fn grey_pictures_dont_ask_the_driver_for_grayscale() {
|
||||
let grey = print::Raster { dpi: 300.0, grayscale: true };
|
||||
let colour = print::Raster { dpi: 300.0, grayscale: false };
|
||||
let job = |args: Value, raster: Option<print::Raster>| {
|
||||
spool_job(&Args(&args), "Office", String::new(), None, Some("Letter"), raster, &[]).unwrap().grayscale
|
||||
};
|
||||
assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey");
|
||||
assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do");
|
||||
assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver");
|
||||
assert!(!job(json!({}), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paper_size_reads_its_numbers_and_needs_a_unit() {
|
||||
assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0)));
|
||||
assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0)));
|
||||
for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] {
|
||||
assert_eq!(paper_size(spec), None, "{spec}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use pdfcraft_engine::{
|
||||
Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
|
||||
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list,
|
||||
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, redacted_file_name,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -200,6 +200,26 @@ impl Automation {
|
||||
let after = self.doc(a)?.redaction_marks();
|
||||
out["applied"] = json!(before - after);
|
||||
out["marks_pending"] = json!(after);
|
||||
// Where the removed text is still held that applying leaves as it is.
|
||||
let left = self.doc(a)?.redaction_elsewhere().unwrap_or_default();
|
||||
out["still_holding"] = json!({
|
||||
"bookmarks": left.bookmarks,
|
||||
"attachments": left.attachments,
|
||||
"comments": left.comments,
|
||||
"xmp": left.xmp,
|
||||
"total": left.total(),
|
||||
"unsearched": left.unsearched,
|
||||
});
|
||||
// What became of a PDF/A or PDF/UA claim in the document XMP the scrub removed.
|
||||
let claim = |c: pdfcraft_engine::RedactionClaim| match c {
|
||||
pdfcraft_engine::RedactionClaim::None => "none",
|
||||
pdfcraft_engine::RedactionClaim::Kept => "kept",
|
||||
pdfcraft_engine::RedactionClaim::Lost => "lost",
|
||||
};
|
||||
let id = self.doc(a)?.redaction_identification().unwrap_or_default();
|
||||
out["identification"] = json!({ "pdfa": claim(id.pdfa), "pdfua": claim(id.pdfua) });
|
||||
// The name to save the redacted copy under, as Save does in the app.
|
||||
out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! document, and manage trusted certificates. Rectangles are points from the top-left of the
|
||||
//! displayed page.
|
||||
|
||||
use pdfcraft_engine::js::SignEvent;
|
||||
use pdfcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey};
|
||||
use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus};
|
||||
use serde_json::{Value, json};
|
||||
@@ -175,6 +176,8 @@ impl Automation {
|
||||
Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) }))
|
||||
}
|
||||
|
||||
/// The digital ID `id` names: a .p12/.pfx file opened with `password`, or an OS key store
|
||||
/// identity (`keychain:` on macOS, `windows:` on Windows).
|
||||
fn open_id(&self, a: &Args) -> Result<DigitalId> {
|
||||
// A macOS Keychain identity: "keychain:<fingerprint>" or "keychain:<common name>".
|
||||
if let Some(r) = a.str("id")?.strip_prefix("keychain:") {
|
||||
@@ -197,6 +200,8 @@ impl Automation {
|
||||
})
|
||||
}
|
||||
|
||||
/// `sign_document`: sign the document with a digital ID into `out`, visibly (in a field or a
|
||||
/// rectangle) or not, running a dynamic form's signing scripts around it.
|
||||
pub(crate) fn sign_document(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.open_id(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
@@ -246,30 +251,94 @@ impl Automation {
|
||||
Some(v) => labels_arg(v)?,
|
||||
};
|
||||
let out = self.resolve(a.str("out")?, true)?;
|
||||
let opts = SignOptions {
|
||||
field,
|
||||
page,
|
||||
rect,
|
||||
reason: a.opt_str("reason")?.map(str::to_string),
|
||||
location: a.opt_str("location")?.map(str::to_string),
|
||||
contact: a.opt_str("contact")?.map(str::to_string),
|
||||
certify,
|
||||
appearance,
|
||||
appearance_labels,
|
||||
graphic,
|
||||
..SignOptions::default()
|
||||
// A dynamic form's signing scripts run only when asked: what preSign changes is signed,
|
||||
// and the caller hasn't seen it. What they changed is listed.
|
||||
let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false);
|
||||
// Every argument is read before the preSign scripts change anything, so a bad one is
|
||||
// refused with the document as it was.
|
||||
let reason = a.opt_str("reason")?.map(str::to_string);
|
||||
let location = a.opt_str("location")?.map(str::to_string);
|
||||
let contact = a.opt_str("contact")?.map(str::to_string);
|
||||
let signing = field.clone();
|
||||
let scripted = signing.as_deref().is_some_and(|f| {
|
||||
self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After)
|
||||
});
|
||||
// Each field's value and whether it is read-only, to tell what the preSign scripts changed.
|
||||
let values = |me: &Automation| -> std::collections::BTreeMap<String, (Vec<String>, bool)> {
|
||||
me.session.get(doc_id).map(|d| d.form.iter().map(|f| (f.name.clone(), (f.value.clone(), f.read_only()))).collect()).unwrap_or_default()
|
||||
};
|
||||
let was = values(self);
|
||||
let before = match &signing {
|
||||
Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?,
|
||||
_ => false,
|
||||
};
|
||||
let changed: Vec<Value> = if before {
|
||||
values(self)
|
||||
.into_iter()
|
||||
.filter(|(name, now)| was.get(name) != Some(now))
|
||||
.map(|(name, (value, read_only))| {
|
||||
let (old, old_ro) = was.get(&name).cloned().unwrap_or_default();
|
||||
json!({ "field": name, "before": old, "after": value, "read_only_before": old_ro, "read_only": read_only })
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let opts =
|
||||
SignOptions { field, page, rect, reason, location, contact, certify, appearance, appearance_labels, graphic, ..SignOptions::default() };
|
||||
let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e {
|
||||
// A graphic the signer refuses is the caller's to fix.
|
||||
pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m),
|
||||
e => failed(e),
|
||||
})?;
|
||||
write_atomic(&out, &signed)?;
|
||||
});
|
||||
let signed = match signed {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Not signed: what the preSign scripts did is taken back.
|
||||
if before {
|
||||
let _ = self.session.undo(doc_id);
|
||||
let _ = self.session.take_js_output(doc_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Err(e) = write_atomic(&out, &signed) {
|
||||
// Not saved, so not signed: what the preSign scripts did is taken back too.
|
||||
if before {
|
||||
let _ = self.session.undo(doc_id);
|
||||
let _ = self.session.take_js_output(doc_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
let path = out.to_string_lossy().into_owned();
|
||||
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
|
||||
// Then its postSign scripts; what they change is a change after signing, not saved yet.
|
||||
// The file is signed and saved by now: a postSign step that fails is reported, not an error.
|
||||
let (after, after_error) = match &signing {
|
||||
Some(f) if run_scripts => match self.session.run_sign_event(doc_id, f, SignEvent::After) {
|
||||
Ok(ran) => (ran, None),
|
||||
Err(e) => (true, Some(format!("the postSign step couldn't finish: {e}"))),
|
||||
},
|
||||
_ => (false, None),
|
||||
};
|
||||
let mut scripts = self.session.take_js_output(doc_id);
|
||||
scripts.errors.extend(after_error);
|
||||
let doc = self.doc(a)?;
|
||||
let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned();
|
||||
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
|
||||
let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? });
|
||||
if before || after {
|
||||
result["form_scripts"] = json!({
|
||||
"before_signing": before,
|
||||
"after_signing": after,
|
||||
"changed": changed,
|
||||
"alerts": scripts.alerts.iter().map(crate::a11y::form_tools::alert_json).collect::<Vec<_>>(),
|
||||
"errors": scripts.errors,
|
||||
"unsaved_changes": self.doc(a)?.dirty,
|
||||
});
|
||||
} else if scripted {
|
||||
result["form_scripts"] = json!({ "skipped": true, "why": "the field has signing scripts; pass run_form_scripts: true to run them" });
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
|
||||
|
||||
@@ -38,6 +38,29 @@ fn search_document(a: &mut Automation, pages: usize) -> DocId {
|
||||
a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_fill_cannot_print_but_js_run_can() {
|
||||
let mut a = Automation::new();
|
||||
let doc = search_document(&mut a, 1);
|
||||
json(&mut a, "form_add_field", json!({ "doc": doc.0, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "Qty" }));
|
||||
json(
|
||||
&mut a,
|
||||
"form_set_script",
|
||||
json!({ "doc": doc.0, "field": "Qty", "event": "validate", "script": "this.print(); console.println('validated');" }),
|
||||
);
|
||||
json(&mut a, "form_fill", json!({ "doc": doc.0, "values": { "Qty": "4" } }));
|
||||
let output = a.session.take_js_output(doc);
|
||||
assert!(output.requests.is_empty(), "{output:?}");
|
||||
assert!(output.console.contains(&"validated".to_string()), "{output:?}");
|
||||
assert!(output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{output:?}");
|
||||
assert!(output.errors.is_empty(), "{output:?}");
|
||||
let fields = json(&mut a, "form_fields", json!({ "doc": doc.0 }));
|
||||
let qty = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "Qty").unwrap();
|
||||
assert_eq!(qty["value"], "4");
|
||||
let explicit = json(&mut a, "js_run", json!({ "doc": doc.0, "script": "this.print();" }));
|
||||
assert_eq!(explicit["requests"], json!([{ "print": true }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() {
|
||||
let mut a = Automation::new();
|
||||
@@ -707,3 +730,27 @@ fn text_extract_html_has_one_budget_across_pages() {
|
||||
assert_eq!(capped["pages"][1]["html"], "", "no HTML after the budget ran out: {capped}");
|
||||
assert_eq!(capped["pages"][1]["styles_incomplete"], true);
|
||||
}
|
||||
|
||||
/// Replace through the tool when a page's text can't be read for the find bar: the page is named
|
||||
/// and counted as refused, in the result, and the readable pages are still replaced.
|
||||
#[test]
|
||||
fn replace_all_reports_a_page_whose_text_cannot_be_read() {
|
||||
let mut a = Automation::new();
|
||||
let id = search_document(&mut a, 3);
|
||||
let mut extract = |bytes: &Arc<Vec<u8>>, password, pages: &[usize]| {
|
||||
extract_parallel(bytes, password, pages)
|
||||
.into_iter()
|
||||
.map(|(p, result)| (p, if p == 1 { Err("synthetic page 2 failure".to_owned()) } else { result }))
|
||||
.collect()
|
||||
};
|
||||
let r = a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet" })), &mut extract).unwrap();
|
||||
assert_eq!(r["replaced"], 3, "{r}");
|
||||
assert_eq!((r["skipped"].clone(), r["skipped_by"]["refused"].clone()), (json!(1), json!(1)), "{r}");
|
||||
let why = r["refusals"][0].as_str().unwrap();
|
||||
assert!(why.starts_with("page 2:") && why.contains("synthetic page 2 failure"), "{why}");
|
||||
// Replacing the first match stops at an unreadable page before it: nothing is replaced.
|
||||
let r =
|
||||
a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet", "all": false, "pages": [2, 3] })), &mut extract).unwrap();
|
||||
assert_eq!((r["replaced"].clone(), r["changed"].clone(), r["skipped_by"]["refused"].clone()), (json!(0), json!(false), json!(1)), "{r}");
|
||||
assert!(r["refusals"][0].as_str().unwrap().starts_with("page 2:"), "{r}");
|
||||
}
|
||||
+112
-28
@@ -171,11 +171,23 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])),
|
||||
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
|
||||
t("doc_convert_colours", "Convert colours", "Print production: every colour operator in the pages' content and the artwork it draws rewritten in the target space (to: gray, rgb or cmyk) by the standard device formulas (device spaces, ICC by component count, Lab, Indexed, Separation and DeviceN tints; patterns, shadings and inline images stay). Not a prepress separation: no ICC profile and no output intent is used. Spot colours are converted, not kept: exact through a Type 2 tint, otherwise a gray of their darkness; /None stays. preserve_black (default true) keeps pure black as 100% K / gray 0; images (default true) re-encodes 8-bit gray and RGB pictures (JPEG stays JPEG, compressed again at quality 80; CMYK written lossless; soft masks kept), and leaves indexed, CMYK, masked and other pictures as they are; annotations also converts the pages' comment and field appearances. Returns how many colours and pictures changed on how many pages, images_left (pictures not in the target that stayed), and loses_conformance (the PDF/A level and output intents the file declared: it may no longer meet them, and still declares them; standards_remove_id takes that off). Undoable.")
|
||||
.cmd("prepress.convert_colors")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "pages": pages("to convert (default all)"),
|
||||
"to": { "type": "string", "enum": ["gray", "rgb", "cmyk"] },
|
||||
"preserve_black": { "type": "boolean" }, "images": { "type": "boolean" }, "annotations": { "type": "boolean" },
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.")
|
||||
.with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])),
|
||||
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited. A path that ends in open parameters, as help systems pass them (`manual.pdf#page=12`, `#nameddest=Intro` or `#Intro`), opens the file and reports its destination as link_open does; a path that names a file opens as it is.")
|
||||
.cmd("file.open")
|
||||
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
|
||||
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.")
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments (and, for a PDF portfolio, the one it presents first), fonts, security, repair notes and the objects that could not be read.")
|
||||
.ro()
|
||||
.cmd("file.properties")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -200,7 +212,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
)),
|
||||
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx:<name> when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a picture left out because its JBIG2 mask is past a decoder limit, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
|
||||
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
|
||||
@@ -264,6 +276,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
|
||||
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." },
|
||||
"stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])),
|
||||
t("text_replace", "Replace text", "Find and replace (Edit text ▸ Replace): each match of find, with the find bar's rules (whitespace-normalised; case-insensitive unless case_sensitive; whole_words: no letter or digit may adjoin a match), is rewritten inside its own paragraph, which keeps its fonts, sizes and colours and rewraps; the replacement takes the style of the run it lands in. A longer replacement is measured in the paragraph's fonts and is not shrunk or hyphenated: one row grows right to the page's margin, then wraps; several rows keep their width. New rows go below and nothing else moves, so they can overlap what follows, unreported; a word wider than the width overruns it. replace may be empty (deletes the match). all (default true) replaces every match on pages (default all) as one undo step; false only the first match the find bar shows (page text order; when no paragraph holds it nothing is replaced and it is reported as not_editable). Matches are left alone and counted in skipped_by when they span two paragraphs (spans_paragraphs), are invisible text over a scan (scanned_text), would need another font because the paragraph's own can't draw the new text (missing_glyphs), the paragraph can't be rewritten (refused, with refusals), or the page text holds them outside any editable paragraph, such as form fields, comments or text inside a form XObject (not_editable, counted when all). A page whose text can't be read is counted once as refused and named in refusals. Returns replaced, skipped, skipped_by, the 1-based pages changed and whether the document changed.")
|
||||
.cmd("edit.replace")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "find": { "type": "string", "minLength": 1 }, "replace": { "type": "string" },
|
||||
"pages": pages("to search (default: all)"),
|
||||
"case_sensitive": { "type": "boolean" }, "whole_words": { "type": "boolean" },
|
||||
"all": { "type": "boolean", "description": "Every match (default) or only the first." },
|
||||
}),
|
||||
&["doc", "find", "replace"],
|
||||
)),
|
||||
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
|
||||
.cmd("page.rotate")
|
||||
.with(schema(
|
||||
@@ -324,7 +347,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
|
||||
&["doc", "pages"],
|
||||
)),
|
||||
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
|
||||
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000), .txt files and Office documents (Word, Excel, PowerPoint, OpenDocument, RTF; through an installed LibreOffice) are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
|
||||
.cmd("page.combine")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -338,7 +361,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["paths"],
|
||||
)),
|
||||
t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.")
|
||||
t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files, Office documents through an installed LibreOffice) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.")
|
||||
.cmd("create.multiple")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -508,15 +531,29 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "from", "to"],
|
||||
)),
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there).")
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there). A check box whose widgets have different on states also lists them as options, and which one is checked (null when none). Combo and list boxes include selected: the 1-based positions in options of the selected ones, which tells apart options that share an export value.")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
"form_fill",
|
||||
"Fill in form fields",
|
||||
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
|
||||
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
|
||||
t(
|
||||
"form_focus",
|
||||
"Move the focus into or out of a field",
|
||||
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run; on another form, the widget's own On Focus or On Blur JavaScript runs, if it has one (see also js_event). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"field": { "type": "string", "description": "Field name (from form_fields)." },
|
||||
"event": { "type": "string", "enum": ["enter", "exit"] },
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
|
||||
}),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])),
|
||||
@@ -563,7 +600,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "page", "rect", "rows"],
|
||||
)),
|
||||
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.")
|
||||
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). line adds a text line under the picture with {name} (the author), {date} and {time} filled in now, as a dynamic stamp. Undoable.")
|
||||
.cmd("comment.stamp")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -575,6 +612,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"file_page": { "type": "integer", "minimum": 1 },
|
||||
"name": { "type": "string" },
|
||||
"author": { "type": "string" },
|
||||
"line": { "type": "string", "maxLength": 200 },
|
||||
}),
|
||||
&["doc", "page", "path"],
|
||||
)),
|
||||
@@ -593,11 +631,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
&["doc", "page", "path", "rect"],
|
||||
)),
|
||||
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact document-local identity from form_fields; requires matching field name."}, "path": { "type": "string" } }), &["doc", "field", "path"])),
|
||||
t(
|
||||
"form_set_props",
|
||||
"Field properties",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise; the field keeps its rectangle and its content turns inside it. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -621,7 +659,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"flags": { "type": "object", "additionalProperties": { "type": "boolean" } },
|
||||
"font_size": { "type": "number", "minimum": 0 },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." },
|
||||
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
|
||||
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. The field keeps its rectangle; its content turns inside it." },
|
||||
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
|
||||
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
|
||||
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." },
|
||||
@@ -678,7 +716,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"redact_apply",
|
||||
"Apply redactions",
|
||||
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.",
|
||||
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too; when the document XMP goes, identification says what became of its PDF/A and PDF/UA claims (pdfa, pdfua: none, kept when written back in a packet holding only the identification, or lost). still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.",
|
||||
)
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])),
|
||||
@@ -688,7 +726,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_hidden_info",
|
||||
"Find hidden information",
|
||||
"What Remove Hidden Information would remove, by category (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data) with counts.",
|
||||
"What Remove Hidden Information would remove, by category (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data) with counts. A page that can't be read in full (its content streams don't decode, or they or a form they draw hold bytes the parser can't read or an inline image whose end is a guess) counts as one item of hidden-text and of hidden-layers: removing either category then fails on that page. With form-fields removed too, a field's appearance becomes page content and is read the same way, though it isn't counted here.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -716,10 +754,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("printer_options", "List printer options", "A printer driver's own job options (CUPS: from its PPD), such as the paper tray, paper type or finishing: key, label, group, default and choices. Pass chosen values to doc_print as options. Empty on Windows, where the driver's preferences window holds them.")
|
||||
.ro()
|
||||
.with(schema(json!({ "printer": { "type": "string" } }), &["printer"])),
|
||||
t(
|
||||
"printer_papers",
|
||||
"List printer paper sizes",
|
||||
"The media sizes (names and dimensions in points) and default paper supported by a printer driver (CUPS: its PPD's, by the driver's names, the default being its *DefaultPageSize; a job on one, or on a standard size of one, is fed it under the PPD's own keyword). Pass printer: a name from printers, or \"default\".",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(json!({ "printer": { "type": "string" } }), &["printer"])),
|
||||
t(
|
||||
"doc_print",
|
||||
"Print",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, US #10 Envelope, a driver paper name from printer_papers, or dimensions \"<w>x<h>\"; when printer is given and paper is omitted, uses the printer's default paper if available; use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -737,18 +782,20 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"auto_rotate": { "type": "boolean" },
|
||||
"booklet_subset": { "type": "string", "enum": ["both", "front", "back"] },
|
||||
"binding": { "type": "string", "enum": ["left", "right"] },
|
||||
"creep": { "type": "number", "minimum": 0, "maximum": 10, "description": "Booklet creep compensation: points each sheet's pages move toward the fold, times the sheet's place from the outside (default 0)." },
|
||||
"overlap": { "type": "number", "minimum": 0 },
|
||||
"cut_marks": { "type": "boolean" },
|
||||
"orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] },
|
||||
"comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] },
|
||||
"paper": { "type": "string" },
|
||||
"paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." },
|
||||
"path": { "type": "string" },
|
||||
"printer": { "type": "string" },
|
||||
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
|
||||
"collate": { "type": "boolean" },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." },
|
||||
"grayscale": { "type": "boolean" },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." },
|
||||
"area": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Print a selected area: [x0, y0, x1, y1] in points from the top-left of the displayed page, of the one page in pages, sized by layout fit/actual/shrink/custom." },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." },
|
||||
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
|
||||
"options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." },
|
||||
}),
|
||||
@@ -824,10 +871,10 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
|
||||
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.")
|
||||
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral. With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])),
|
||||
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
|
||||
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. A named destination is looked up in the target, as the app does; one the target doesn't define is reported with a warning. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
|
||||
&["doc", "page", "index"],
|
||||
@@ -880,7 +927,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
|
||||
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
|
||||
.cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])),
|
||||
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).")
|
||||
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark); the points that shape a line, polygon or callout, and a callout's text box. Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`. comment_delete deletes just the member named, as Acrobat does (Replace Text's strikeout and caret go together).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])),
|
||||
t(
|
||||
@@ -958,7 +1005,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("sign.digital")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys). On a dynamic (XFA) form, run_form_scripts: true runs the field's own signing scripts: preSign just before signing, so what it changes is signed too (form_scripts.changed lists each field it changed, with the values before and after), and postSign after, left unsaved. Without it they don't run, and form_scripts.skipped says the field has them.")
|
||||
.cmd("sign.digital")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1009,6 +1056,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"out": { "type": "string", "description": "Where to save the signed document." },
|
||||
"run_form_scripts": { "type": "boolean", "description": "Run a dynamic form's preSign and postSign scripts for the field (default false)." },
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
)),
|
||||
@@ -1051,7 +1099,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp, or a callout's text box: its arrow point stays and its leader line re-attaches), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, the corners of connected lines, a polygon or a cloud, or a callout's arrow point and knee, as comment_list gives them). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
@@ -1065,13 +1113,18 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"move": point(),
|
||||
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
|
||||
"points": { "type": "array", "items": point(), "minItems": 1, "description": "A line's two ends, every corner of connected lines, a polygon or a cloud, or a callout's arrow point and (optionally) knee, in page coordinates. A callout given its arrow point alone keeps the knee it has." },
|
||||
})),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
|
||||
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
|
||||
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
|
||||
t("comment_popup", "Open, close or move a comment's pop-up", "Open or close a comment's pop-up window (open), or move it (rect, in page view coordinates as comment_list reports). A comment without a pop-up gets one beside it. Text boxes, Fill & Sign marks and replies have none of their own. Undoable.")
|
||||
.with(schema(comment_ref(json!({
|
||||
"open": { "type": "boolean" },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
})), &["doc"])),
|
||||
t(
|
||||
"comments_copy",
|
||||
"Copy comments",
|
||||
@@ -1206,13 +1259,27 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("doc_printer_marks", "Add printer marks", "Print production: trim marks, bleed marks (dashed, at the bleed box), registration targets, colour bars and a page-information line (label, page n of N, date) drawn outside the trim box as a removable page mark, in registration colour (Separation All, so on every plate) except the process-ink colour bars; the page is widened by make_room points a side when the marks would fall outside it (the trim box stays). replace (default true) replaces marks added before. Undoable; doc_remove_marks kind printer_marks takes them off.")
|
||||
.cmd("prepress.marks")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "pages": pages("the pages to mark (default all)"),
|
||||
"trim": { "type": "boolean" }, "bleed": { "type": "boolean" }, "registration": { "type": "boolean" }, "color_bars": { "type": "boolean" }, "page_info": { "type": "boolean" },
|
||||
"line_weight": { "type": "number", "minimum": 0.1, "maximum": 4, "description": "Points (Acrobat offers 0.25, 0.5 and 1)." },
|
||||
"make_room": { "type": "number", "maximum": 288, "description": "Points to widen the page by on each side when needed (default 36); negative leaves the page as it is." },
|
||||
"label": { "type": "string", "description": "The page-information line's label (default: the file's name)." },
|
||||
"date": { "type": "string", "description": "The page-information line's date (default: today)." },
|
||||
"replace": { "type": "boolean" },
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.")
|
||||
.ro()
|
||||
.cmd("edit.background")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])),
|
||||
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.")
|
||||
t("doc_remove_marks", "Remove header & footer, watermark, background or printer marks", "Remove every header and footer, watermark, background or printer marks PdfCraft (or a compatible tool) added. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
|
||||
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background", "printer_marks"] } }), &["doc", "kind"])),
|
||||
t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`<name>_page_<n>.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.")
|
||||
.cmd("export.image")
|
||||
.with(schema(
|
||||
@@ -1348,12 +1415,27 @@ 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 alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
|
||||
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 and subsequent field scripts' alerts (each with its message, title, icon and buttons, in order; AcroForm replies consume ordered answers; missing replies cancel input or use the alert button set's safe default), console output, requests (print, page, url, submit, attachment), error, and the errors of document-level scripts that failed (the rest still ran), plus warnings from applying the changes.")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields; requires matching field name and is scoped to this document."}, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096},"description":"Ordered popup, response and alert answers; strings are limited to 4096 UTF-8 bytes at runtime; null cancels a popup or response, and selects an alert default (OK, Cancel or No, by button set)."} }),
|
||||
&["doc", "script"],
|
||||
)),
|
||||
t(
|
||||
"js_event",
|
||||
"Run a field's pointer or focus script",
|
||||
"Run the JavaScript action widget `widget` of AcroForm field `field` has for `event`, as when the pointer enters (mouse_enter), leaves (mouse_exit), presses (mouse_down) or releases (mouse_up) it, or it gains (on_focus) or loses (on_blur) the focus, in the document's script realm (variables document scripts and earlier scripts left are kept), with event.name Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur. Nothing runs when the widget has no JavaScript for that event, JavaScript is off, or the form is a laid-out XFA form (use form_focus and js_run there). What it changes is one undoable edit. Pointer-over and focus scripts run unasked in a viewer, so their requests to print, open Save As or show a panel are dropped (noted on the console); mouse_down and mouse_up keep them. Returns changed, and the script's alerts, console output, requests and errors.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"field": { "type": "string", "description": "Field name (from form_fields)." },
|
||||
"event": { "type": "string", "enum": ["mouse_enter", "mouse_exit", "mouse_down", "mouse_up", "on_focus", "on_blur"] },
|
||||
"field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields: of fields sharing the name, the one to run."},
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
|
||||
}),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.")
|
||||
.ro()
|
||||
.cmd("tools.document_js")
|
||||
@@ -1678,7 +1760,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
|
||||
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; a picture's stencil /Mask and one-bit /SMask are stored again the same way, at their size, not resampled and sharing no dictionary, counted as images; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
|
||||
.cmd("optimize.advanced")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1694,6 +1776,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"discard_thumbnails": { "type": "boolean" },
|
||||
"discard_alternate_images": { "type": "boolean" },
|
||||
"discard_tags": { "type": "boolean" },
|
||||
"discard_search_index": { "type": "boolean" },
|
||||
"discard_print_settings": { "type": "boolean" },
|
||||
"discard_form_actions": { "type": "boolean" },
|
||||
"flate_unencoded": { "type": "boolean" },
|
||||
@@ -1701,6 +1784,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"lzw_to_flate": { "type": "boolean" },
|
||||
"remove_invalid_links": { "type": "boolean" },
|
||||
"remove_unreferenced_dests": { "type": "boolean" },
|
||||
"discard_external_refs": { "type": "boolean" },
|
||||
"discard": { "type": "array", "items": { "type": "string" } },
|
||||
}),
|
||||
&["doc", "path"],
|
||||
|
||||
@@ -19,14 +19,14 @@ fn one_step(step: Step) -> Action {
|
||||
/// The standard fonts can't draw the character, so the watermark step fails and no file is produced.
|
||||
#[test]
|
||||
fn failing_watermark_step_fails_the_file_and_publishes_nothing() {
|
||||
let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), |_, _| {});
|
||||
let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), true, |_, _| {});
|
||||
assert!(r.is_err(), "a failed step must fail its file: {r:?}");
|
||||
}
|
||||
|
||||
/// A comment step on a document with no comments has nothing to do, which is not a failure.
|
||||
#[test]
|
||||
fn nothing_to_do_step_still_succeeds() {
|
||||
let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), |_, _| {});
|
||||
let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), true, |_, _| {});
|
||||
assert!(r.is_ok(), "a step with nothing to do is not a failure: {r:?}");
|
||||
}
|
||||
|
||||
@@ -61,6 +61,6 @@ fn action_run_marks_the_failing_file_as_error_without_output() {
|
||||
/// A removal step with nothing to remove keeps its existing note and succeeds.
|
||||
#[test]
|
||||
fn removal_step_with_nothing_to_remove_still_succeeds() {
|
||||
let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), |_, _| {});
|
||||
let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), true, |_, _| {});
|
||||
assert!(r.is_ok(), "nothing to remove is not a failure: {r:?}");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Changing an existing caption regenerates the saved appearance and preserves form values.
|
||||
fn button_caption_can_be_changed_after_creation() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
@@ -34,11 +35,16 @@ fn button_caption_can_be_changed_after_creation() {
|
||||
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = a.session().save_bytes(DocId(id)).unwrap();
|
||||
let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
|
||||
let result = tool(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({ "doc": id, "field": "clear_button", "script": "getField('clear_button').value='ignored'; getField('clear_button').buttonSetCaption('Clear Form'); getField('clear_button').buttonGetCaption();" }),
|
||||
);
|
||||
assert_eq!(result["result"], "Clear Form");
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
|
||||
assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
|
||||
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
|
||||
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Run JavaScript"));
|
||||
let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
|
||||
tool(&mut a, "edit_undo", json!({"doc": id}));
|
||||
@@ -89,3 +95,69 @@ fn button_caption_can_be_changed_after_creation() {
|
||||
assert_eq!(pixels, empty_pixels);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// An unindexed setter reaches both widgets even when the first caption already matches.
|
||||
#[test]
|
||||
fn javascript_caption_updates_all_widgets_with_undo_and_save() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-js-multi-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
|
||||
for (name, caption, y) in [("first", "First", 300.0), ("second", "Second", 240.0)] {
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddField {
|
||||
page: 0,
|
||||
rect: [40.0, y, 200.0, y + 30.0],
|
||||
kind: NewField::Button { caption: caption.into() },
|
||||
name: Some(name.into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
|
||||
let form = doc.get(af);
|
||||
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
|
||||
let widgets: Vec<_> = fields.as_array().unwrap().iter().map(|w| w.as_ref().unwrap()).collect();
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
parent.set(b"Ff".to_vec(), Object::Int(1 << 16));
|
||||
parent.set(b"T".to_vec(), PdfString::text("go"));
|
||||
parent.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect()));
|
||||
let parent = doc.add(parent);
|
||||
for w in widgets {
|
||||
doc.update_dict(w, |d| {
|
||||
for key in [b"FT".as_slice(), b"Ff", b"T"] {
|
||||
d.remove(key);
|
||||
}
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
|
||||
std::fs::write(dir.join("buttons.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
|
||||
let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
let result = tool(&mut a, "js_run", json!({"doc": id, "script": "getField('go').buttonSetCaption('First'); getField('go').buttonGetCaption();"}));
|
||||
assert_eq!(result["error"], Value::Null);
|
||||
assert_eq!(result["result"], "First");
|
||||
let expected = json!([{"widget": 1, "caption": "First"}, {"widget": 2, "caption": "First"}]);
|
||||
assert_eq!(tool(&mut a, "form_fields", json!({"doc": id}))["fields"][0]["captions"], expected);
|
||||
let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_ne!(before, after, "the second widget must visibly change");
|
||||
tool(&mut a, "edit_undo", json!({"doc": id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before);
|
||||
tool(&mut a, "edit_redo", json!({"doc": id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after);
|
||||
tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
|
||||
let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(tool(&mut a, "form_fields", json!({"doc": reopened}))["fields"][0]["captions"], expected);
|
||||
assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! A form's document actions and pointer/focus scripts through the tool table (#269, ported
|
||||
//! from #344): the open action runs on doc_open, Will Save on doc_save, Will Print on
|
||||
//! doc_print, and js_event runs a widget's pointer and focus scripts, all in one realm.
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(value) => value,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn pdf_of(objs: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// `log` records what ran; `hot` has Mouse Enter and On Blur scripts. The open action counts
|
||||
/// in a variable the Will Save and Will Print actions and `hot`'s scripts read.
|
||||
fn form() -> Vec<u8> {
|
||||
pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OpenAction 6 0 R /AA << /WS 7 0 R /WP 8 0 R >> /AcroForm << /Fields [4 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 9 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /S /JavaScript /JS (var n = 1; getField\\('log'\\).value = 'open' + n;) >>".into(),
|
||||
"<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' save' + n; console.println\\('saving'\\);) >>".into(),
|
||||
"<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' print' + n;) >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (n++; getField\\('log'\\).value += ' ' + event.name + n;) >> /Bl << /S /JavaScript /JS (this.print\\(\\); getField\\('log'\\).value += ' ' + event.name;) >> >> >>".into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn log(a: &mut Automation, doc: u64) -> Value {
|
||||
let f = tool(a, "form_fields", json!({ "doc": doc }));
|
||||
f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_will_save_will_print_and_field_events_share_the_document_realm() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-document-actions-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("form.pdf"), form()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
|
||||
let doc = tool(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(log(&mut a, doc), "open1");
|
||||
|
||||
let entered = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "mouse_enter" }));
|
||||
assert_eq!(entered["changed"], true, "{entered}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2");
|
||||
// Losing the focus happens unasked: its print request is dropped and noted.
|
||||
let blurred = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "on_blur" }));
|
||||
assert_eq!(blurred["requests"], json!([]), "{blurred}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur");
|
||||
let none = tool(&mut a, "js_event", json!({ "doc": doc, "field": "log", "event": "mouse_down" }));
|
||||
assert_eq!(none["changed"], false, "no script: {none}");
|
||||
assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "keystroke" })).is_err());
|
||||
assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "mouse_up", "widget": 2 })).is_err());
|
||||
|
||||
// Saved in place (incrementally): object numbers stay, so the realm is kept. A full
|
||||
// rewrite starts a fresh realm for the next event.
|
||||
let saved = tool(&mut a, "doc_save", json!({ "doc": doc }));
|
||||
assert_eq!(saved["incremental"], true, "{saved}");
|
||||
std::fs::copy(dir.join("form.pdf"), dir.join("saved.pdf")).unwrap();
|
||||
assert_eq!(saved["will_save"]["console"], json!(["saving"]), "{saved}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3");
|
||||
|
||||
let printed = tool(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" }));
|
||||
assert!(printed.get("will_print").is_none(), "nothing to report: {printed}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4");
|
||||
// A print with nowhere to go fails before Will Print runs.
|
||||
assert!(a.call("doc_print", &json!({ "doc": doc })).is_err());
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4");
|
||||
|
||||
// What Will Save changed is in the saved file.
|
||||
tool(&mut a, "js_enabled", json!({ "enabled": false }));
|
||||
let reopened = tool(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(log(&mut a, reopened), "open1 Mouse Enter2 Blur save3");
|
||||
}
|
||||
|
||||
/// Of two fields sharing a name, `field_id` (from form_fields) picks the one js_event runs; by
|
||||
/// name alone it is the first.
|
||||
#[test]
|
||||
fn js_event_runs_the_field_its_field_id_names() {
|
||||
let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>");
|
||||
let pdf = pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")),
|
||||
]);
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-js-event-dup-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("dup.pdf"), pdf).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = tool(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = tool(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let ids: Vec<u64> = fields["fields"].as_array().unwrap().iter().filter(|f| f["name"] == "dup").map(|f| f["field_id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ids.len(), 2, "{fields}");
|
||||
tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "field_id": ids[1], "event": "mouse_enter" }));
|
||||
assert_eq!(log(&mut a, doc), "second;");
|
||||
tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "event": "mouse_enter" }));
|
||||
assert_eq!(log(&mut a, doc), "second;first;");
|
||||
assert!(
|
||||
a.call("js_event", &json!({ "doc": doc, "field": "log", "field_id": ids[1], "event": "mouse_enter" })).is_err(),
|
||||
"the id is another field's"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `text_find` on a ligature glyph named in the encoding and on a word divided by a hyphen at a
|
||||
//! line end.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A fresh directory for one test.
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pdfcraft-find-text-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One page: `of<fi>ce` in a Helvetica whose code 128 is named `/fi`, and `exam-` at the end of
|
||||
/// one line with `ple` starting the next.
|
||||
fn find_pdf() -> Vec<u8> {
|
||||
let body = "BT /F2 14 Tf 90 700 Td (of\\200ce) Tj ET\nBT /F1 14 Tf 90 650 Td (exam-) Tj 0 -16 Td (ple) Tj ET\n";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>"
|
||||
.to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// "office" finds the ligature, and "example" both halves of the divided word, highlighted line by
|
||||
/// line; the hyphenated form is still found as written.
|
||||
#[test]
|
||||
fn text_find_matches_ligatures_and_words_divided_at_a_line_end() {
|
||||
let dir = tmp("forms");
|
||||
std::fs::write(dir.join("find.pdf"), find_pdf()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "find.pdf" }))["doc"].as_u64().unwrap();
|
||||
let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q }));
|
||||
|
||||
let office = find(&mut a, "office");
|
||||
assert_eq!(office["count"], 1, "{office}");
|
||||
let rects = office["matches"][0]["rects"].as_array().unwrap();
|
||||
assert_eq!(rects.len(), 1);
|
||||
assert!((rects[0][0].as_f64().unwrap() - 90.0).abs() < 0.5, "from the left edge of the o: {rects:?}");
|
||||
|
||||
let example = find(&mut a, "example");
|
||||
assert_eq!(example["count"], 1, "{example}");
|
||||
assert_eq!(example["matches"][0]["text"], "exam-\nple");
|
||||
let rects = example["matches"][0]["rects"].as_array().unwrap();
|
||||
assert_eq!(rects.len(), 2, "one highlight on each line: {rects:?}");
|
||||
let top = |k: usize| rects[k][1].as_f64().unwrap();
|
||||
assert!(top(1) > top(0) + 10.0, "the second on the next line down: {rects:?}");
|
||||
|
||||
for q in ["exam-ple", "Exam- ple", "exam-"] {
|
||||
assert_eq!(find(&mut a, q)["count"], 1, "{q}");
|
||||
}
|
||||
assert_eq!(find(&mut a, "examples")["count"], 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Which options a list box has selected, through the tool table (ISO 32000-2 Table 234, `/I`).
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(value) => value,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF of the given objects (numbered from 1).
|
||||
fn pdf_of(objs: &[&str]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn open(test: &str) -> (Automation, u64, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-list-selection-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bytes = pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (D_dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (H_twovals) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 150 200 220] /P 3 0 R /F 4 >>",
|
||||
]);
|
||||
std::fs::write(dir.join("choice.pdf"), bytes).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = tool(&mut a, "doc_open", json!({ "path": "choice.pdf" }))["doc"].as_u64().unwrap();
|
||||
(a, id, dir)
|
||||
}
|
||||
|
||||
fn field(a: &mut Automation, doc: u64, name: &str) -> Value {
|
||||
let all = tool(a, "form_fields", json!({ "doc": doc }));
|
||||
all["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap().clone()
|
||||
}
|
||||
|
||||
/// Two options share the export value `1`: `selected` says which one `/I` picks, and filling the
|
||||
/// field with an option's label picks that option.
|
||||
#[test]
|
||||
fn form_fields_reports_which_of_two_options_with_one_export_value_is_selected() {
|
||||
let (mut a, doc, dir) = open("dup");
|
||||
let d = field(&mut a, doc, "D_dup");
|
||||
assert_eq!(d["value"], "1");
|
||||
assert_eq!(d["selected"], json!([2]), "One-B, the second option: {d}");
|
||||
tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-A" } }));
|
||||
let d = field(&mut a, doc, "D_dup");
|
||||
assert_eq!((d["value"].clone(), d["selected"].clone()), (json!("1"), json!([1])), "{d}");
|
||||
tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-B" } }));
|
||||
let d = field(&mut a, doc, "D_dup");
|
||||
assert_eq!(d["selected"], json!([2]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A list box without MultiSelect given `/V [(TW) (JP)]` reports Taiwan alone (ISO 32000-2
|
||||
/// Table 233: "at most one item shall be selected"), and filling it writes one value.
|
||||
#[test]
|
||||
fn form_fields_reports_one_value_for_a_single_select_list_given_two() {
|
||||
let (mut a, doc, dir) = open("twovals");
|
||||
let h = field(&mut a, doc, "H_twovals");
|
||||
assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("TW"), json!([1])), "{h}");
|
||||
tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "H_twovals": "United States" } }));
|
||||
let h = field(&mut a, doc, "H_twovals");
|
||||
assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("US"), json!([3])), "{h}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -9,13 +9,19 @@ mod batch;
|
||||
mod batch_failures;
|
||||
mod batch_secrets;
|
||||
mod button_caption;
|
||||
mod document_actions;
|
||||
mod find_text;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
mod list_selection;
|
||||
mod model_3d;
|
||||
mod model_render;
|
||||
mod office;
|
||||
mod optimize_content_boundaries;
|
||||
mod portfolio_info;
|
||||
mod redact_after_stray_q_form;
|
||||
mod redact_text_after_q;
|
||||
mod render_warnings;
|
||||
mod text_copy;
|
||||
mod timesheet_compat;
|
||||
mod unreadable_objects;
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Office documents converted by a real LibreOffice through the tools that take them
|
||||
//! (`doc_create_multiple`, `doc_combine`), with the converted text checked. Every test here
|
||||
//! skips when LibreOffice isn't installed, which is the case on CI; run them on a machine that
|
||||
//! has it. The Word, Excel and PowerPoint files are written here, as the smallest packages
|
||||
//! LibreOffice reads, so no binary fixtures are committed.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfcraft_automation::{Automation, Content, ToolError};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn auto(dir: &Path) -> Automation {
|
||||
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
|
||||
}
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each page's text, with runs of whitespace made single spaces.
|
||||
fn page_text(a: &mut Automation, doc: u64) -> Vec<String> {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["text"].as_str().unwrap().split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A ZIP of `entries`, stored without compression (Office packages may be).
|
||||
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = !0u32;
|
||||
for &b in data {
|
||||
crc ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
let (mut out, mut central) = (Vec::new(), Vec::new());
|
||||
for (name, data) in entries {
|
||||
let (crc, size, offset) = (crc32(data.as_bytes()), data.len() as u32, out.len() as u32);
|
||||
// version 2.0, no flags, stored, 1980-01-01 00:00
|
||||
let common = [&20u16.to_le_bytes()[..], &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0x21u16.to_le_bytes()].concat();
|
||||
let sizes = [crc.to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat();
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&common);
|
||||
out.extend_from_slice(&sizes);
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(data.as_bytes());
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&20u16.to_le_bytes());
|
||||
central.extend_from_slice(&common);
|
||||
central.extend_from_slice(&sizes);
|
||||
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
// extra, comment, disk, internal and external attributes
|
||||
central.extend_from_slice(&[0u8; 12]);
|
||||
central.extend_from_slice(&offset.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let (at, count) = (out.len() as u32, entries.len() as u16);
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0u8; 4]);
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&at.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
const XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#;
|
||||
const PACKAGE_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
const DOC_RELS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
/// `[Content_Types].xml` with one override per (part, type).
|
||||
fn content_types(parts: &[(&str, &str)]) -> String {
|
||||
let overrides: String = parts.iter().map(|(part, kind)| format!(r#"<Override PartName="/{part}" ContentType="{kind}"/>"#)).collect();
|
||||
format!(
|
||||
r#"{XML}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>{overrides}</Types>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A relationships part with one relationship per (id, type under officeDocument, target).
|
||||
fn rels(list: &[(&str, &str, &str)]) -> String {
|
||||
let items: String =
|
||||
list.iter().map(|(id, kind, target)| format!(r#"<Relationship Id="{id}" Type="{DOC_RELS}/{kind}" Target="{target}"/>"#)).collect();
|
||||
format!(r#"{XML}<Relationships xmlns="{PACKAGE_RELS}">{items}</Relationships>"#)
|
||||
}
|
||||
|
||||
/// A Word document of one paragraph.
|
||||
fn docx(text: &str) -> Vec<u8> {
|
||||
let document = format!(
|
||||
r#"{XML}<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
zip(&[
|
||||
(
|
||||
"[Content_Types].xml",
|
||||
&content_types(&[("word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml")]),
|
||||
),
|
||||
("_rels/.rels", &rels(&[("rId1", "officeDocument", "word/document.xml")])),
|
||||
("word/document.xml", &document),
|
||||
])
|
||||
}
|
||||
|
||||
/// An Excel workbook whose one sheet holds `text` in A1 and a formula, `=6*7`, in A2.
|
||||
fn xlsx(text: &str) -> Vec<u8> {
|
||||
let workbook = format!(
|
||||
r#"{XML}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{DOC_RELS}"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>"#
|
||||
);
|
||||
let sheet = format!(
|
||||
r#"{XML}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>{text}</t></is></c></row><row r="2"><c r="A2"><f>6*7</f></c></row></sheetData></worksheet>"#
|
||||
);
|
||||
zip(&[
|
||||
(
|
||||
"[Content_Types].xml",
|
||||
&content_types(&[
|
||||
("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"),
|
||||
("xl/worksheets/sheet1.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"),
|
||||
]),
|
||||
),
|
||||
("_rels/.rels", &rels(&[("rId1", "officeDocument", "xl/workbook.xml")])),
|
||||
("xl/workbook.xml", &workbook),
|
||||
("xl/_rels/workbook.xml.rels", &rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")])),
|
||||
("xl/worksheets/sheet1.xml", &sheet),
|
||||
])
|
||||
}
|
||||
|
||||
/// A PowerPoint presentation of one slide with one text box.
|
||||
fn pptx(text: &str) -> Vec<u8> {
|
||||
const P: &str =
|
||||
r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#;
|
||||
let presentation = format!(
|
||||
r#"{XML}<p:presentation {P} xmlns:r="{DOC_RELS}"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>"#
|
||||
);
|
||||
let tree = r#"<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>"#;
|
||||
let master = format!(
|
||||
r#"{XML}<p:sldMaster {P} xmlns:r="{DOC_RELS}">{tree}</p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>"#
|
||||
);
|
||||
let layout = format!(r#"{XML}<p:sldLayout {P}>{tree}</p:spTree></p:cSld></p:sldLayout>"#);
|
||||
let slide = format!(
|
||||
r#"{XML}<p:sld {P}>{tree}<p:sp><p:nvSpPr><p:cNvPr id="2" name="Text"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="6000000" cy="1000000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:p><a:r><a:rPr lang="en-US" sz="2800"/><a:t>{text}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#
|
||||
);
|
||||
let theme = format!(
|
||||
r#"{XML}<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Plain"><a:themeElements><a:clrScheme name="Plain"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="000000"/></a:dk2><a:lt2><a:srgbClr val="FFFFFF"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="Plain"><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="Plain"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#
|
||||
);
|
||||
const PML: &str = "application/vnd.openxmlformats-officedocument.presentationml";
|
||||
let types = content_types(&[
|
||||
("ppt/presentation.xml", &format!("{PML}.presentation.main+xml")),
|
||||
("ppt/slideMasters/slideMaster1.xml", &format!("{PML}.slideMaster+xml")),
|
||||
("ppt/slideLayouts/slideLayout1.xml", &format!("{PML}.slideLayout+xml")),
|
||||
("ppt/slides/slide1.xml", &format!("{PML}.slide+xml")),
|
||||
("ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml"),
|
||||
]);
|
||||
zip(&[
|
||||
("[Content_Types].xml", &types),
|
||||
("_rels/.rels", &rels(&[("rId1", "officeDocument", "ppt/presentation.xml")])),
|
||||
("ppt/presentation.xml", &presentation),
|
||||
(
|
||||
"ppt/_rels/presentation.xml.rels",
|
||||
&rels(&[
|
||||
("rId1", "slideMaster", "slideMasters/slideMaster1.xml"),
|
||||
("rId2", "slide", "slides/slide1.xml"),
|
||||
("rId3", "theme", "theme/theme1.xml"),
|
||||
]),
|
||||
),
|
||||
("ppt/slideMasters/slideMaster1.xml", &master),
|
||||
(
|
||||
"ppt/slideMasters/_rels/slideMaster1.xml.rels",
|
||||
&rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), ("rId2", "theme", "../theme/theme1.xml")]),
|
||||
),
|
||||
("ppt/slideLayouts/slideLayout1.xml", &layout),
|
||||
("ppt/slideLayouts/_rels/slideLayout1.xml.rels", &rels(&[("rId1", "slideMaster", "../slideMasters/slideMaster1.xml")])),
|
||||
("ppt/slides/slide1.xml", &slide),
|
||||
("ppt/slides/_rels/slide1.xml.rels", &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml")])),
|
||||
("ppt/theme/theme1.xml", &theme),
|
||||
])
|
||||
}
|
||||
|
||||
/// An RTF document of one paragraph.
|
||||
fn rtf(text: &str) -> Vec<u8> {
|
||||
format!("{{\\rtf1\\ansi{{\\fonttbl\\f0\\fswiss Helvetica;}}\\f0\\pard {text}\\par}}").into_bytes()
|
||||
}
|
||||
|
||||
/// A folder with one Word, Excel, PowerPoint and RTF file, each carrying its own word, and the
|
||||
/// tools rooted there; `None` when LibreOffice isn't installed.
|
||||
fn office_files() -> Option<(tempfile::TempDir, Automation)> {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("letter.docx"), docx("Quokka letter")).unwrap();
|
||||
std::fs::write(dir.path().join("budget.xlsx"), xlsx("Narwhal budget")).unwrap();
|
||||
std::fs::write(dir.path().join("deck.pptx"), pptx("Axolotl deck")).unwrap();
|
||||
std::fs::write(dir.path().join("notes.rtf"), rtf("Pangolin notes")).unwrap();
|
||||
let mut a = auto(dir.path());
|
||||
match a.call("doc_create_multiple", &json!({ "paths": ["notes.rtf"] })) {
|
||||
Err(ToolError::Failed(e)) if e.contains("needs LibreOffice") => {
|
||||
eprintln!("skipped: LibreOffice isn't installed");
|
||||
None
|
||||
}
|
||||
_ => Some((dir, a)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each Office format converts to a PDF of its own that holds the document's text (and, for
|
||||
/// the spreadsheet, the formula's value).
|
||||
#[test]
|
||||
fn office_documents_convert_separately_with_their_text() {
|
||||
let Some((dir, mut a)) = office_files() else { return };
|
||||
let paths = ["letter.docx", "budget.xlsx", "deck.pptx", "notes.rtf"];
|
||||
let made = ok(&mut a, "doc_create_multiple", json!({ "paths": paths, "mode": "separate", "out_dir": "out" }));
|
||||
let files = made["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 4, "{made}");
|
||||
let expected = [
|
||||
("letter.pdf", &["Quokka letter"][..]),
|
||||
("budget.pdf", &["Narwhal budget", "42"]),
|
||||
("deck.pdf", &["Axolotl deck"]),
|
||||
("notes.pdf", &["Pangolin notes"]),
|
||||
];
|
||||
for (file, (name, words)) in files.iter().zip(expected) {
|
||||
let output = file["output"].as_str().unwrap_or_else(|| panic!("{file}"));
|
||||
// The tool reports the folder in its canonical form (`\\?\C:\…` on Windows).
|
||||
assert_eq!(Path::new(output).file_name(), Some(std::ffi::OsStr::new(name)), "{output}");
|
||||
assert!(dir.path().join("out").join(name).is_file(), "{name}");
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": output }))["doc"].as_u64().unwrap();
|
||||
let text = page_text(&mut a, doc).join("\n");
|
||||
for word in words {
|
||||
assert!(text.contains(word), "{output}: {word:?} not in {text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Office documents combine, in the order given, with a PDF made here, one bookmark each.
|
||||
#[test]
|
||||
fn office_documents_combine_in_order_with_a_bookmark_each() {
|
||||
let Some((dir, mut a)) = office_files() else { return };
|
||||
let made = ok(
|
||||
&mut a,
|
||||
"doc_create_multiple",
|
||||
json!({ "paths": ["deck.pptx", "letter.docx", "budget.xlsx", "notes.rtf"], "out": "all.pdf", "open": true }),
|
||||
);
|
||||
let doc = made["document"]["doc"].as_u64().unwrap();
|
||||
let pages = page_text(&mut a, doc);
|
||||
assert_eq!(pages.len(), 4, "{pages:?}");
|
||||
for (page, word) in pages.iter().zip(["Axolotl deck", "Quokka letter", "Narwhal budget", "Pangolin notes"]) {
|
||||
assert!(page.contains(word), "{word:?} not in {page:?}");
|
||||
}
|
||||
let titles: Vec<String> = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|b| b["title"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(titles, ["deck", "letter", "budget", "notes"]);
|
||||
assert!(dir.path().join("all.pdf").is_file());
|
||||
|
||||
// doc_combine takes them beside a PDF, with a page range for one of them.
|
||||
std::fs::write(dir.path().join("cover.txt"), "Cover page").unwrap();
|
||||
ok(&mut a, "doc_create_multiple", json!({ "paths": ["cover.txt"], "out": "cover.pdf" }));
|
||||
assert!(dir.path().join("cover.pdf").is_file());
|
||||
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["cover.pdf", "notes.rtf", "letter.docx"], "pages": [null, "1", null], "open": true }));
|
||||
let doc = combined["document"]["doc"].as_u64().unwrap();
|
||||
let pages = page_text(&mut a, doc);
|
||||
assert_eq!(pages.len(), 3, "{pages:?}");
|
||||
for (page, word) in pages.iter().zip(["Cover page", "Pangolin notes", "Quokka letter"]) {
|
||||
assert!(page.contains(word), "{word:?} not in {page:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! `doc_info` reports a PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): the attachment it
|
||||
//! presents first and how its file list first shows.
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Call tool `name` and return its JSON result.
|
||||
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(value) => value,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-page portfolio of `cover.txt` and `form.pdf` with `collection` as its `/Collection`
|
||||
/// entries, or none at all.
|
||||
fn portfolio(collection: Option<&str>) -> Vec<u8> {
|
||||
let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default();
|
||||
format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [(cover.txt) 5 0 R (form.pdf) 7 0 R] >> >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
|
||||
4 0 obj << /Type /EmbeddedFile /Length 5 >> stream
|
||||
hello
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Filespec /F (cover.txt) /UF (cover.txt) /EF << /F 4 0 R >> >> endobj
|
||||
6 0 obj << /Type /EmbeddedFile /Length 8 >> stream
|
||||
%PDF-1.7
|
||||
endstream endobj
|
||||
7 0 obj << /Type /Filespec /F (form.pdf) /UF (form.pdf) /EF << /F 6 0 R >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// `doc_info` names a portfolio's initial document and view, and reports none for a plain file.
|
||||
#[test]
|
||||
fn doc_info_names_the_document_a_portfolio_presents_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("tile.pdf"), portfolio(Some("/D (form.pdf) /View /T"))).unwrap();
|
||||
std::fs::write(dir.path().join("cover.pdf"), portfolio(Some(""))).unwrap();
|
||||
std::fs::write(dir.path().join("plain.pdf"), portfolio(None)).unwrap();
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let mut info = |path: &str| {
|
||||
let doc = tool(&mut auto, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
|
||||
tool(&mut auto, "doc_info", json!({ "doc": doc }))["portfolio"].clone()
|
||||
};
|
||||
assert_eq!(info("tile.pdf"), json!({ "initial": "form.pdf", "view": "tile" }));
|
||||
// Without `/D` the cover sheet is the initial document.
|
||||
assert_eq!(info("cover.pdf"), json!({ "initial": null, "view": "details" }));
|
||||
assert_eq!(info("plain.pdf"), Value::Null);
|
||||
}
|
||||
@@ -96,6 +96,72 @@ fn a_clean_page_renders_to_the_png_alone() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// An image whose Indexed lookup table is too short (ISO 32000-2 §8.6.6.3) is drawn recovered, and
|
||||
/// `page_render` says so in the words `pdfcraft-cli render` prints.
|
||||
#[test]
|
||||
fn page_render_reports_a_recovered_malformed_indexed_image() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-indexed-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let content = "q 200 0 0 200 0 0 cm /Im Do Q";
|
||||
// hival 7 over DeviceRGB needs 24 bytes; the table holds 6.
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 7 <FF0000 00FF00>] /Filter /ASCIIHexDecode /Length 5 >> stream\n0001>\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
);
|
||||
std::fs::write(dir.join("indexed.pdf"), pdf).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = open(&mut a, "indexed.pdf");
|
||||
let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
|
||||
assert_eq!(report["warnings"], json!(["an image with a malformed Indexed colour table may be drawn wrongly or not at all"]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A picture whose JBIG2 soft mask is past one of the decoder's limits isn't drawn (#6722), and
|
||||
/// `page_render` says why the page is incomplete, in the words `pdfcraft-cli render` prints.
|
||||
#[test]
|
||||
fn page_render_says_a_picture_was_left_out_for_its_jbig2_mask() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-jbig2-mask-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// One JBIG2 segment (ISO 14492 §7.4.8): the information of a page 65,536 columns wide, a
|
||||
// column past the decoder's bound on a side, as the mask's dictionary says too.
|
||||
let mut mask = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19];
|
||||
mask.extend_from_slice(&65536u32.to_be_bytes());
|
||||
mask.extend_from_slice(&2u32.to_be_bytes());
|
||||
mask.extend_from_slice(&[0; 11]); // resolution, flags, striping
|
||||
let content = "q 200 0 0 200 0 0 cm /Im Do Q";
|
||||
let mut pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n\
|
||||
6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n",
|
||||
content.len(),
|
||||
mask.len()
|
||||
)
|
||||
.into_bytes();
|
||||
pdf.extend_from_slice(&mask);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
|
||||
std::fs::write(dir.join("mask.pdf"), pdf).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = open(&mut a, "mask.pdf");
|
||||
let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
|
||||
assert_eq!(
|
||||
report["warnings"],
|
||||
json!(["content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"])
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Over MCP the warnings are a text block after the image and the call's `structuredContent`.
|
||||
#[cfg(feature = "mcp")]
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Original synthetic workflow: no private timesheet, employee, PIN or signature assets.
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
|
||||
use pdfcraft_engine::{Edit, NewField, Session};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Dispatch a synthetic automation step and fail with the returned tool error on refusal.
|
||||
fn call(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(v) => v,
|
||||
_ => panic!("expected JSON"),
|
||||
}
|
||||
}
|
||||
/// Construct a synthetic form and transparent Form XObject without private files or font assets.
|
||||
fn fixture() -> Vec<u8> {
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(600.0, 800.0, 1).unwrap();
|
||||
let id = session.open("synthetic.pdf", None, bytes, None).unwrap();
|
||||
for (i, name) in ["input", "double", "seen", "choice", "go", "source", "target"].into_iter().enumerate() {
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddField {
|
||||
page: 0,
|
||||
rect: [40.0, 700.0 - i as f64 * 45.0, 200.0, 730.0 - i as f64 * 45.0],
|
||||
kind: if i >= 4 { NewField::Button { caption: name.into() } } else { NewField::Text { multiline: false } },
|
||||
name: Some(name.into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
let source = session.get(id).unwrap().form.iter().find(|f| f.name == "source").unwrap().obj;
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"ca".to_vec(), Object::Real(0.5));
|
||||
let mut states = Dict::new();
|
||||
states.set(b"Half".to_vec(), Object::Dict(gs));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"ExtGState".to_vec(), Object::Dict(states));
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
dict.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
dict.set(b"BBox".to_vec(), Object::Array([0, 0, 60, 30].map(Object::Int).to_vec()));
|
||||
dict.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
let icon = doc.add(Object::Stream(Stream::from_raw(dict, b"q /Half gs 1 0 0 rg 0 0 60 30 re f Q".to_vec())));
|
||||
let mut mk = Dict::new();
|
||||
mk.set(b"I".to_vec(), Object::Ref(icon));
|
||||
mk.set(b"TP".to_vec(), Object::Int(1));
|
||||
doc.update_dict(source, |d| d.set(b"MK".to_vec(), Object::Dict(mk))).unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
const HELPERS: &str = r#"
|
||||
var initialized=1, buffer='', savedIcon=null;
|
||||
function openPad(){var b=getPageBox('Crop',0);var f=getField('go');f.rect=[b[0]+40,b[1]-400,b[0]+200,b[1]-450];f.textSize=16;f.fillColor=['RGB',0.9,0.8,0.7];f.strokeColor=['G',0.4];f.lineWidth=2;f.borderStyle='dashed';f.textColor=color.blue;f.userName='Synthetic keypad';f.display=display.noPrint;}
|
||||
function digit(d){buffer+=d;getField('go').buttonSetCaption(buffer);if(buffer==='1234'){getField('target').buttonSetIcon(savedIcon);getField('go').display=display.hidden;}}
|
||||
function selectTime(){var v=app.popUpMenuEx({cName:'Unavailable',cReturn:'bad',bEnabled:false},{cName:'Times',oSubMenu:[{cName:'Seven',cReturn:'07:00',bMarked:true},{cName:'Manual',cReturn:'manual'}]});if(v==='manual')v=app.response({cQuestion:'Time',cDefault:'07:00'});if(v!==null)getField('choice').value=v;}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
/// Exercise retained keypad state, geometry, synchronous calculation and transparent icons through save/reopen and Undo/Redo.
|
||||
fn timesheet_keypad_geometry_icons_calculation_and_persistence() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-compat-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
call(&mut a, "form_set_script", json!({"doc":id,"field":"double","event":"calculate","script":"event.value=Number(getField('input').value)*2;"}));
|
||||
call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS}));
|
||||
let out = call(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({"doc":id,"script":"getField('input').value=3;calculateNow();getField('seen').value=getField('double').value;getField('seen').value;"}),
|
||||
);
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "6");
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"script":"openPad();savedIcon=getField('source').buttonGetIcon();initialized;"}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "1");
|
||||
let fields = call(&mut a, "form_fields", json!({"doc":id}));
|
||||
let go = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "go").unwrap();
|
||||
assert_eq!(go["tooltip"], "Synthetic keypad");
|
||||
let model = a.session().get(pdfcraft_engine::DocId(id)).unwrap().form.iter().find(|f| f.name == "go").unwrap();
|
||||
assert_eq!(model.widgets[0].rect, [40.0, 350.0, 200.0, 400.0]);
|
||||
assert!(model.da.contains("16 Tf"), "{}", model.da);
|
||||
for (key, expected) in [("1", "1"), ("2", "12"), ("3", "123")] {
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":format!("digit('{key}');buffer;")}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], expected);
|
||||
}
|
||||
let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":"digit('4');buffer;"}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "1234");
|
||||
let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_ne!(before, after);
|
||||
let blended = after
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|p| p[0] > p[1].saturating_add(50) && (50..180).contains(&p[1]) && (p[1] as i16 - p[2] as i16).abs() <= 2)
|
||||
.count();
|
||||
assert!(blended > 500, "half-opacity red icon must blend across its area with the button's grey background, got {blended} pixels");
|
||||
call(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before);
|
||||
call(&mut a, "edit_redo", json!({"doc":id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after);
|
||||
let rejected = call(&mut a, "js_run", json!({"doc":id,"script":"getField('target').buttonSetIcon({});"}));
|
||||
assert!(rejected["error"].as_str().unwrap().contains("icon"));
|
||||
call(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"}));
|
||||
let saved = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap();
|
||||
let root = saved.get(saved.root().unwrap());
|
||||
let af = saved.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let fields = saved.resolve(af.as_dict().unwrap().get(b"Fields").unwrap());
|
||||
let target = fields
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| saved.resolve(f))
|
||||
.find(|f| f.as_dict().unwrap().get(b"T").and_then(Object::as_string).is_some_and(|s| s.to_text() == "target"))
|
||||
.unwrap();
|
||||
let mk = saved.resolve(target.as_dict().unwrap().get(b"MK").unwrap());
|
||||
let icon = saved.resolve(mk.as_dict().unwrap().get(b"I").unwrap());
|
||||
let Object::Stream(icon) = &*icon else { panic!("icon stream") };
|
||||
assert!(String::from_utf8_lossy(&icon.decoded().unwrap()).contains("/Half gs"));
|
||||
let reopened = call(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after);
|
||||
let reset = call(&mut a, "js_run", json!({"doc":reopened,"script":"buffer;"}));
|
||||
assert_eq!(reset["result"], "");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Resume one invocation across popup/text prompts and reject disabled or invented choices.
|
||||
fn popup_and_manual_response_resume_the_same_script_and_validate_choices() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-menu-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS}));
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":["manual","07:45"]}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "07:45");
|
||||
let cancel = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":[null]}));
|
||||
assert_eq!(cancel["result"], "07:45");
|
||||
let invalid = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();","answers":["bad"]}));
|
||||
assert!(invalid["error"].as_str().unwrap().contains("invalid popup"));
|
||||
let alert = call(&mut a, "js_run", json!({"doc":id,"script":"app.alert({cMsg:'Continue?',nType:2});","answers":["4"]}));
|
||||
assert_eq!(alert["result"], "4");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The advertised schema must bound prompt strings before clients dispatch them.
|
||||
#[test]
|
||||
fn prompt_answer_schema_advertises_the_length_budget() {
|
||||
let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap();
|
||||
assert_eq!(tool.input_schema["properties"]["answers"]["items"]["maxLength"], 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_run_exact_field_ids_are_discoverable_and_strictly_validated() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-field-identity-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
let fields = call(&mut a, "form_fields", json!({"doc":id}));
|
||||
let entries = fields["fields"].as_array().unwrap();
|
||||
let go = entries.iter().find(|f| f["name"] == "go").unwrap()["field_id"].as_u64().unwrap();
|
||||
let other = entries.iter().find(|f| f["name"] == "input").unwrap()["field_id"].as_u64().unwrap();
|
||||
let out = call(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({"doc":id,"field":"go","field_id":go,"script":"event.target.buttonSetCaption(app.response({cQuestion:'Caption'}));event.target.name;","answers":["exact"]}),
|
||||
);
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "go");
|
||||
for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64), json!(other)] {
|
||||
assert!(a.call("js_run", &json!({"doc":id,"field":"go","field_id":invalid,"script":"getField('input').value='bad';"})).is_err());
|
||||
}
|
||||
assert!(a.call("js_run", &json!({"doc":id,"field_id":go,"script":"true;"})).is_err());
|
||||
let schema = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap();
|
||||
assert_eq!(schema.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The headless image picker counterpart accepts exact duplicate identities and keeps legacy lookup.
|
||||
#[test]
|
||||
fn exact_image_field_tool_targets_duplicates_and_rejects_invalid_ids() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-exact-image-tool-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(300.0, 300.0, 1).unwrap();
|
||||
let id = session.open("synthetic.pdf", None, bytes, None).unwrap();
|
||||
for (name, x) in [("first", 20.0), ("second", 160.0)] {
|
||||
session.apply(id, Edit::AddField { page: 0, rect: [x, 20.0, x + 100.0, 80.0], kind: NewField::Image, name: Some(name.into()) }).unwrap();
|
||||
}
|
||||
let mut cos = Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
for field in session.get(id).unwrap().form.iter() {
|
||||
cos.update_dict(field.obj, |d| d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("same"))).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("synthetic.pdf"), write_full(&cos, &Default::default()).unwrap()).unwrap();
|
||||
std::fs::write(dir.join("picture.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
let fields = call(&mut a, "form_fields", json!({"doc":id}));
|
||||
let second = fields["fields"][1]["field_id"].as_u64().unwrap();
|
||||
call(&mut a, "form_set_image", json!({"doc":id,"field":"same","field_id":second,"path":"picture.gif"}));
|
||||
let icons = |a: &mut Automation| {
|
||||
call(a, "doc_save", json!({"doc":id,"path":"saved.pdf"}));
|
||||
let cos = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap();
|
||||
let root = cos.get(cos.root().unwrap());
|
||||
let form = cos.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
form.as_dict()
|
||||
.unwrap()
|
||||
.get(b"Fields")
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
cos.resolve(r)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"MK").map(|v| cos.resolve(v)))
|
||||
.is_some_and(|m| m.as_dict().is_some_and(|d| d.contains(b"I")))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(icons(&mut a), [false, true]);
|
||||
for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64)] {
|
||||
assert!(a.call("form_set_image", &json!({"doc":id,"field":"same","field_id":invalid,"path":"picture.gif"})).is_err());
|
||||
}
|
||||
assert!(a.call("form_set_image", &json!({"doc":id,"field":"missing","field_id":second,"path":"picture.gif"})).is_err());
|
||||
assert_eq!(icons(&mut a), [false, true]);
|
||||
call(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(icons(&mut a), [false, false]);
|
||||
call(&mut a, "form_set_image", json!({"doc":id,"field":"same","path":"picture.gif"}));
|
||||
assert_eq!(icons(&mut a), [true, false]);
|
||||
let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "form_set_image").unwrap();
|
||||
assert_eq!(tool.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -58,6 +58,23 @@ impl Op {
|
||||
pub fn last_name(&self) -> Option<&[u8]> {
|
||||
self.operands.last().and_then(Object::as_name)
|
||||
}
|
||||
|
||||
/// Operand `k` counted from the end (0 is the last), as the renderer reads an operator's
|
||||
/// operands: `(x) /OC /MC0 BDC` marks with `/OC /MC0`, and `(x) /F1 10 Tf` selects `/F1`.
|
||||
pub fn back(&self, k: usize) -> Option<&Object> {
|
||||
let i = self.operands.len().checked_sub(k)?.checked_sub(1)?;
|
||||
self.operands.get(i)
|
||||
}
|
||||
|
||||
/// [`Op::back`] as a name.
|
||||
pub fn name_back(&self, k: usize) -> Option<&[u8]> {
|
||||
self.back(k).and_then(Object::as_name)
|
||||
}
|
||||
|
||||
/// [`Op::back`] as a number.
|
||||
pub fn num_back(&self, k: usize) -> Option<f64> {
|
||||
self.back(k).and_then(Object::as_f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed stream.
|
||||
|
||||
@@ -10,6 +10,17 @@ thread_local! {
|
||||
pub(crate) static PIECE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operands_are_counted_from_the_end() {
|
||||
let op = parse(b"(x) /OC /MC0 BDC").ops.remove(0);
|
||||
assert_eq!((op.name_back(1), op.name_back(0)), (Some(&b"OC"[..]), Some(&b"MC0"[..])));
|
||||
assert_eq!(op.back(2).and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"x".to_vec()));
|
||||
assert_eq!((op.back(3), op.back(usize::MAX)), (None, None));
|
||||
let op = parse(b"/F1 10 Tf").ops.remove(0);
|
||||
assert_eq!((op.name_back(1), op.num_back(0), op.num_back(1)), (Some(&b"F1"[..]), Some(10.0), None));
|
||||
assert_eq!(Op::new("Q", vec![]).back(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splicing_visits_each_piece_a_few_times() {
|
||||
let n = 5_000;
|
||||
|
||||
+161
-2
@@ -246,12 +246,14 @@ pub struct DecodeBudget {
|
||||
bytes: usize,
|
||||
forms: usize,
|
||||
seen: HashSet<ObjRef>,
|
||||
/// Decodes refused for lack of allowance ([`DecodeBudget::refused`]).
|
||||
refusals: usize,
|
||||
}
|
||||
|
||||
impl DecodeBudget {
|
||||
/// An allowance of `bytes` decoded bytes and `forms` form entries.
|
||||
pub fn new(bytes: usize, forms: usize) -> Self {
|
||||
Self { limit: bytes, bytes, forms, seen: HashSet::new() }
|
||||
Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 }
|
||||
}
|
||||
|
||||
/// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms.
|
||||
@@ -264,6 +266,22 @@ impl DecodeBudget {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
/// How many decodes were refused for lack of allowance: one that came once it was spent, or
|
||||
/// ran past what was left. A decode that spent exactly what was left wasn't refused, so an
|
||||
/// allowance used up to the byte has none.
|
||||
pub fn refused(&self) -> usize {
|
||||
self.refusals
|
||||
}
|
||||
|
||||
/// Whether the allowance is spent and `stream`, which has data, would be refused, noting the
|
||||
/// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's
|
||||
/// parameters, work the allowance doesn't count. A stream without data still reads.
|
||||
pub fn refuses(&mut self, stream: &Stream) -> bool {
|
||||
let refuses = self.bytes == 0 && !stream.raw.is_empty();
|
||||
self.refusals += usize::from(refuses);
|
||||
refuses
|
||||
}
|
||||
|
||||
/// Whether a decode was refused for lack of allowance or a form entry for lack of visits.
|
||||
pub fn exhausted(&self) -> bool {
|
||||
self.bytes == 0 || self.forms == 0
|
||||
@@ -283,6 +301,51 @@ impl DecodeBudget {
|
||||
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
|
||||
}
|
||||
|
||||
/// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as
|
||||
/// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the
|
||||
/// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares
|
||||
/// its data and copies no more of its dictionary than its filters and parameters
|
||||
/// ([`DecodeBudget::decode_with_damage`] reads it so). A
|
||||
/// stream so reads here when it reads without an allowance and the allowance can pay for
|
||||
/// what its stages decode, the output of one stage on top of another's. A stage past `max`
|
||||
/// is an error that charges what the stages before it decoded and `max`, the work it took to
|
||||
/// find out; past the stages' total it charges that total, and past what is left it spends
|
||||
/// all of it, as [`DecodeBudget::decode_within`] does.
|
||||
pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
// Before the parameters are resolved, work the allowance doesn't count.
|
||||
if self.refuses(stream) {
|
||||
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
|
||||
}
|
||||
let stream = doc.decoding_view(stream);
|
||||
let total = max.saturating_mul(stream.filters().len().max(1));
|
||||
let room = total.min(self.bytes);
|
||||
match stream.decoded_within_each_counted(max, room) {
|
||||
Ok((data, _, spent)) => {
|
||||
self.bytes = self.bytes.saturating_sub(spent);
|
||||
Ok(data)
|
||||
}
|
||||
// A stage stopped at `max`, below what was left of the room.
|
||||
Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => {
|
||||
self.bytes = self.bytes.saturating_sub(spent.saturating_add(max));
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(max).to_string()))
|
||||
}
|
||||
// At the stages' total, with more left.
|
||||
Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => {
|
||||
self.bytes = self.bytes.saturating_sub(total);
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(total).to_string()))
|
||||
}
|
||||
Err((FilterError::LimitExceeded(_), _)) => {
|
||||
self.bytes = 0;
|
||||
self.refusals += 1;
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
|
||||
}
|
||||
Err((e, spent)) => {
|
||||
self.bytes = self.bytes.saturating_sub(spent);
|
||||
Err(CosError::Filter(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
|
||||
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
|
||||
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
|
||||
@@ -304,7 +367,7 @@ impl DecodeBudget {
|
||||
/// pays for the bytes decoded before the error, so referencing a stream again and again
|
||||
/// spends the allowance by the work each decode does.
|
||||
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec<u8>, bool), CosError> {
|
||||
if self.bytes == 0 && !stream.raw.is_empty() {
|
||||
if self.refuses(stream) {
|
||||
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
|
||||
}
|
||||
if max < self.bytes {
|
||||
@@ -324,6 +387,7 @@ impl DecodeBudget {
|
||||
}
|
||||
Err((FilterError::LimitExceeded(_), _)) => {
|
||||
self.bytes = 0;
|
||||
self.refusals += 1;
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
|
||||
}
|
||||
Err((e, spent)) => {
|
||||
@@ -1025,4 +1089,99 @@ mod tests {
|
||||
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
|
||||
}
|
||||
|
||||
/// `decode_stages_within` holds every stage to the limit, as a decode without an allowance
|
||||
/// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and
|
||||
/// Flate whose stages each stay within it reads, the hex stage's output charged on top; one
|
||||
/// whose Flate stage would make more stops there, charged what the hex stage made and the
|
||||
/// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves
|
||||
/// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a
|
||||
/// stream past what is left spends all of it.
|
||||
#[test]
|
||||
fn each_stage_is_held_to_the_limit() {
|
||||
let chained = |data: &[u8], filters: [&str; 2]| {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect()));
|
||||
Stream::from_raw(dict, data.to_vec())
|
||||
};
|
||||
let hexed = |data: &[u8]| {
|
||||
let flate = Stream::flate(Dict::new(), data);
|
||||
let hex: Vec<u8> = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect();
|
||||
(chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len())
|
||||
};
|
||||
let max = 1 << 16;
|
||||
let (fits, inner) = hexed(&vec![0u8; max]);
|
||||
let mut budget = DecodeBudget::new(1 << 20, 0);
|
||||
assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max));
|
||||
assert_eq!(budget.remaining(), (1 << 20) - inner - max);
|
||||
let (bomb, inner) = hexed(&vec![0u8; 8 * max]);
|
||||
let mut budget = DecodeBudget::new(1 << 20, 0);
|
||||
assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err());
|
||||
assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit");
|
||||
// Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together
|
||||
// make 1.8 times the limit, within twice it, but the Flate stage alone is past it.
|
||||
let text = "00".repeat(max * 6 / 10).into_bytes();
|
||||
let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]);
|
||||
assert!(wide.decoded_within(max).is_err());
|
||||
assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err());
|
||||
let mut budget = DecodeBudget::new(max, 0);
|
||||
assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err());
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
|
||||
/// What `decode_stages_within` charges, and which limit it reports, on each way a read stops:
|
||||
/// a stage at the stream's limit with more of the room left charges what came before and the
|
||||
/// limit; at the stages' total, with more of the allowance left, it charges that total; at
|
||||
/// what is left of the allowance it spends all of it and reports the allowance's limit, as
|
||||
/// `decode_within` does. Each boundary between them is read on both sides.
|
||||
#[test]
|
||||
fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() {
|
||||
let max = 1 << 12;
|
||||
let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::<Vec<u8>>();
|
||||
let two = |raw: &[u8]| {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
Stream::from_raw(dict, hex(raw))
|
||||
};
|
||||
let says = |read: Result<Vec<u8>, CosError>, limit: usize| {
|
||||
read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string())
|
||||
};
|
||||
// Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and
|
||||
// the Flate stage stops at the limit.
|
||||
let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec();
|
||||
let inner = bomb.len();
|
||||
// A byte more of the room than the hex stage and the limit: the Flate stage stops at the
|
||||
// limit, so that is what it charges.
|
||||
let mut budget = DecodeBudget::new(inner + max + 1, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max));
|
||||
assert_eq!(budget.remaining(), 1);
|
||||
// Exactly the hex stage and the limit: what was left stopped it as much as the limit did,
|
||||
// so it is past the allowance, spends it and reports the allowance's limit.
|
||||
let mut budget = DecodeBudget::new(inner + max, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// The same data padded to exactly the limit: the hex stage makes all of it, and the Flate
|
||||
// stage, at the limit, reaches the stages' total. With more of the allowance left, that
|
||||
// total is what it charges and reports.
|
||||
let mut padded = bomb.clone();
|
||||
padded.resize(max, 0);
|
||||
let mut budget = DecodeBudget::new(10 * max, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max));
|
||||
assert_eq!(budget.remaining(), 8 * max);
|
||||
// With exactly the total left (of an allowance of three times the limit), it is past the
|
||||
// allowance: all of it is spent, and the allowance's limit reported.
|
||||
let mut budget = DecodeBudget::new(3 * max, 0);
|
||||
budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap();
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// With less than the total left, the same.
|
||||
let mut budget = DecodeBudget::new(max, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// With nothing left, a stream with data is refused before it is decoded, even one that
|
||||
// decodes to nothing; an empty one reads.
|
||||
let mut budget = DecodeBudget::new(0, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0));
|
||||
assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new()));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod reader;
|
||||
mod state;
|
||||
mod xref;
|
||||
|
||||
pub use objects::DECODE_PARMS_KEYS;
|
||||
pub(crate) use objects::Held;
|
||||
#[cfg(test)]
|
||||
pub(crate) use reader::find;
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::collections::HashSet;
|
||||
|
||||
use super::reader::find;
|
||||
use super::state::{
|
||||
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member,
|
||||
overlay_reads,
|
||||
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadFrame, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read,
|
||||
note_read, objstm_member, overlay_reads,
|
||||
};
|
||||
use super::xref::{Revision, XrefEntry, XrefIndex};
|
||||
use super::{
|
||||
@@ -244,6 +244,12 @@ impl Document {
|
||||
|
||||
pub(super) fn try_get_reference(&self, r: ObjRef) -> Result<Arc<Object>, CosError> {
|
||||
if !self.reference_matches(r) {
|
||||
// An edit of `r.num` can make it match, so a load that read null here read it too;
|
||||
// with `r.num` edited (freed, or set at another generation) the null is the edit's.
|
||||
if self.overlay.contains_key(&r.num) {
|
||||
note_overlay_read();
|
||||
}
|
||||
note_read(r.num, None);
|
||||
return Ok(Arc::new(Object::Null));
|
||||
}
|
||||
self.try_get(r.num)
|
||||
@@ -507,17 +513,6 @@ impl Document {
|
||||
pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter)
|
||||
}
|
||||
|
||||
/// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's
|
||||
/// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without
|
||||
/// globals. Redaction finds with it the other images that share a redacted image's globals.
|
||||
pub fn jbig2_globals_ref(&self, s: &Stream) -> Option<ObjRef> {
|
||||
let chain = s.readable_filters().ok()?;
|
||||
if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
|
||||
return None;
|
||||
}
|
||||
self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref)
|
||||
}
|
||||
|
||||
/// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`)
|
||||
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is
|
||||
/// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read
|
||||
@@ -565,22 +560,32 @@ impl Document {
|
||||
|
||||
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
|
||||
if let Some(slot) = self.overlay.get(&num) {
|
||||
note_read(num, None);
|
||||
note_overlay_read();
|
||||
return Ok(match slot {
|
||||
Slot::Set(_, o) | Slot::RepairedSet(_, _, o) => o.clone(),
|
||||
Slot::Freed(_) => Arc::new(Object::Null),
|
||||
});
|
||||
}
|
||||
if let Some(loaded) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
|
||||
// Clones share the cache: what a load read that this document has edited since (the
|
||||
// member's object stream, a `/Length`) makes the entry another document's.
|
||||
if let Some((loaded, reads)) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num)
|
||||
&& !reads.touch(&self.overlay)
|
||||
{
|
||||
note_read(num, Some(reads));
|
||||
return loaded.clone();
|
||||
}
|
||||
note_read(num, None);
|
||||
// Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
|
||||
// cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
|
||||
let _guard =
|
||||
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
|
||||
let _scope = crate::security::FollowScope::enter();
|
||||
let edits_read = overlay_reads();
|
||||
let frame = ReadFrame::enter();
|
||||
let loaded = self.load(num, 0).map(Arc::new);
|
||||
let reads = frame.reads();
|
||||
drop(frame);
|
||||
if let Err(e) = &loaded
|
||||
&& !matches!(e, CosError::Poisoned)
|
||||
{
|
||||
@@ -592,7 +597,7 @@ impl Document {
|
||||
if overlay_reads() == edits_read && !matches!(loaded, Err(CosError::Poisoned)) {
|
||||
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
|
||||
// is cheap, and returned objects outlive eviction.
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone(), loaded_bytes(&loaded, &self.data));
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone(), loaded_bytes(&loaded, &self.data), reads);
|
||||
self.evict_editor_cache_if_over_budget(num)?;
|
||||
}
|
||||
loaded
|
||||
@@ -735,9 +740,14 @@ impl Document {
|
||||
pub(super) fn objstm(&self, num: u32) -> Result<Arc<ObjStm>, CosError> {
|
||||
// An edited or freed stream is this document's alone: clones share the parsed streams.
|
||||
let edited = self.overlay.contains_key(&num);
|
||||
if !edited && let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
|
||||
if !edited
|
||||
&& let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num)
|
||||
&& !s.reads.touch(&self.overlay)
|
||||
{
|
||||
note_read(num, Some(&s.reads));
|
||||
return Ok(s.clone());
|
||||
}
|
||||
let frame = ReadFrame::enter();
|
||||
// A file entry that cannot place the stream (one that says it is itself compressed
|
||||
// inside another stream, as in #2367, or points nowhere) is tried by the stream's own
|
||||
// header, as a plain object with a wrong offset is. An edited or freed stream, in the
|
||||
@@ -751,8 +761,6 @@ impl Document {
|
||||
}
|
||||
_ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?),
|
||||
};
|
||||
// A stream whose load read this document's edits (its `/Length`) is as much its own as an edited one.
|
||||
let own = edited || overlay_reads() != edits_read;
|
||||
let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
|
||||
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
|
||||
let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok();
|
||||
@@ -769,8 +777,10 @@ impl Document {
|
||||
let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break };
|
||||
index.push(objstm_member(onum, off, first, data.len()));
|
||||
}
|
||||
let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
|
||||
if own {
|
||||
let stm = Arc::new(ObjStm { index, data: Arc::new(data), reads: frame.reads() });
|
||||
// A stream whose load or decoding read this document's edits (its `/Length`, its
|
||||
// `/DecodeParms`) is as much its own as an edited one.
|
||||
if edited || overlay_reads() != edits_read {
|
||||
return Ok(stm);
|
||||
}
|
||||
// Same bound as the object cache: decoded object streams can be megabytes each.
|
||||
@@ -788,17 +798,21 @@ impl Document {
|
||||
note_overlay_read();
|
||||
}
|
||||
let cached = loading();
|
||||
// A length resolved through this document's edits counts as an edit read on every reuse.
|
||||
if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
|
||||
// Each reuse counts as a read of the length and of what resolving it read, and as an edit
|
||||
// read when that went through this document's edits.
|
||||
if cached && let Some((known, edits, reads)) = LENGTHS.with(|m| m.borrow().get(&r).cloned()) {
|
||||
if edits {
|
||||
note_overlay_read();
|
||||
}
|
||||
note_read(r.num, Some(&reads));
|
||||
return known;
|
||||
}
|
||||
note_read(r.num, None);
|
||||
let edits_read = overlay_reads();
|
||||
let frame = ReadFrame::enter();
|
||||
let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
|
||||
if cached {
|
||||
LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read)));
|
||||
LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read, frame.reads())));
|
||||
}
|
||||
length
|
||||
}
|
||||
@@ -858,7 +872,6 @@ impl Document {
|
||||
pub fn set(&mut self, r: ObjRef, obj: impl Into<Object>) {
|
||||
let obj = self.adopt(obj.into());
|
||||
self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj)));
|
||||
self.forget_member_failures(r.num);
|
||||
if r.num >= self.next_num {
|
||||
self.next_num = r.num.saturating_add(1);
|
||||
}
|
||||
@@ -918,16 +931,6 @@ impl Document {
|
||||
/// Delete an object (its number becomes free with the next generation).
|
||||
pub fn free(&mut self, r: ObjRef) {
|
||||
self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
|
||||
self.forget_member_failures(r.num);
|
||||
}
|
||||
|
||||
/// Forget cached failures of the members `stream` held in the file: they were failures of
|
||||
/// the file's copy, and an edit of `stream` may read. Clones share the cache, so theirs are
|
||||
/// searched again too, which costs time only. A poisoned lock leaves the cache as it is.
|
||||
fn forget_member_failures(&self, stream: u32) {
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
cache.forget_failures(|n| matches!(self.entries.lookup(*n, &self.data), Some(XrefEntry::InStream { stream: s, .. }) if s == stream));
|
||||
}
|
||||
}
|
||||
|
||||
/// Free what was added since the file was read and nothing refers to any more: the objects
|
||||
@@ -1079,7 +1082,7 @@ impl Document {
|
||||
/// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and
|
||||
/// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by
|
||||
/// reference, which [`Dict::int`] doesn't follow.
|
||||
const DECODE_PARMS_KEYS: [&[u8]; 13] = [
|
||||
pub const DECODE_PARMS_KEYS: [&[u8]; 13] = [
|
||||
b"Predictor",
|
||||
b"Colors",
|
||||
b"BitsPerComponent",
|
||||
|
||||
@@ -320,10 +320,25 @@ impl Document {
|
||||
Ok(Some((section, stream.dict.clone(), true)))
|
||||
}
|
||||
|
||||
/// Read the cross-reference chain from `startxref` into one map of entries, newer sections
|
||||
/// winning, with the trailer, the revisions oldest first and the set-aside entries. A chain
|
||||
/// that lists more than [`MAX_XREF_OBJECTS`] rows in all is refused with
|
||||
/// [`CosError::Syntax`], and the caller rebuilds the file from object headers (#5082).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn read_xref_chain_materialized(
|
||||
&self,
|
||||
log: &mut Vec<String>,
|
||||
) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>, Vec<String>), CosError> {
|
||||
self.read_xref_chain_materialized_with_limit(log, MAX_XREF_OBJECTS)
|
||||
}
|
||||
|
||||
/// [`Self::read_xref_chain_materialized`] refusing a chain past `row_limit` rows in all
|
||||
/// instead of [`MAX_XREF_OBJECTS`], so tests can reach the limit with a few rows.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn read_xref_chain_materialized_with_limit(
|
||||
&self,
|
||||
log: &mut Vec<String>,
|
||||
row_limit: usize,
|
||||
) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>, Vec<String>), CosError> {
|
||||
let data = &self.data;
|
||||
let tail_start = data.len().saturating_sub(4096);
|
||||
@@ -347,6 +362,9 @@ impl Document {
|
||||
let mut generation_repairs = Vec::new();
|
||||
let mut recorded = HashSet::new();
|
||||
let mut diagnose = true;
|
||||
// Every row the sections (and their hybrid streams) list, counted by `read_section` as it
|
||||
// reads them, before any are merged or deduplicated.
|
||||
let mut merged_rows = 0usize;
|
||||
let mut next = Some(first);
|
||||
while let Some(off) = next.take() {
|
||||
if !visited.insert(off) || visited.len() > 10_000 {
|
||||
@@ -357,13 +375,15 @@ impl Document {
|
||||
let mut sect = BTreeMap::new();
|
||||
// Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]).
|
||||
let mut odd = Vec::new();
|
||||
let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) {
|
||||
let rows_before = merged_rows;
|
||||
let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, &mut merged_rows, log) {
|
||||
Ok(v) => (v, false),
|
||||
Err(e) if self.header_offset > 0 => {
|
||||
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
|
||||
sect.clear();
|
||||
odd.clear();
|
||||
(self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true)
|
||||
merged_rows = rows_before;
|
||||
(self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, &mut merged_rows, log)?, true)
|
||||
}
|
||||
Err(e) => {
|
||||
// An unreadable older section loses every object only it locates (the page
|
||||
@@ -398,7 +418,7 @@ impl Document {
|
||||
}
|
||||
} else if let Some(x) = hybrid {
|
||||
let mut stm = BTreeMap::new();
|
||||
let read = match self.read_section(x as usize, &mut stm, &mut odd, log) {
|
||||
let read = match self.read_section(x as usize, &mut stm, &mut odd, &mut merged_rows, log) {
|
||||
Ok(_) => {
|
||||
for (&num, &entry) in &stm {
|
||||
if !diagnose {
|
||||
@@ -429,6 +449,22 @@ impl Document {
|
||||
hybrids.insert(x, read);
|
||||
}
|
||||
}
|
||||
// Each section's rows are capped, so the chain is too: at most `row_limit` rows
|
||||
// (MAX_XREF_OBJECTS outside tests) read in all, or the file is rebuilt from object
|
||||
// headers, as when one section's stream decodes past that. This counts every row
|
||||
// read, before deduplication, not the map's size: a chain that relists the same
|
||||
// objects, or a stream whose `/Index` repeats a range (`[0 4 0 4]`), costs as much to
|
||||
// merge (#5082). Rows set aside are among the rows read, and a row is set aside at
|
||||
// most twice (in its section and against a hybrid stream), so they stay bounded too. A
|
||||
// section's rows (its hybrid stream's included) never outnumber the rows the compact
|
||||
// reader counts for it, and that reader stops at about a quarter of MAX_XREF_OBJECTS:
|
||||
// no chain within its limit is refused.
|
||||
if merged_rows > row_limit {
|
||||
return Err(CosError::Syntax {
|
||||
offset: usize::try_from(off).unwrap_or(usize::MAX),
|
||||
detail: format!("cross-reference chain lists more than {row_limit} entries"),
|
||||
});
|
||||
}
|
||||
oddities.extend(odd.into_iter().map(|num| (off, num)));
|
||||
for (num, e) in sect {
|
||||
entries.entry(num).or_insert(e);
|
||||
@@ -447,12 +483,14 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
|
||||
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]).
|
||||
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]), and
|
||||
/// `counted` grows by every row read, repeated and set-aside rows included (#5082).
|
||||
fn read_section(
|
||||
&self,
|
||||
off: usize,
|
||||
entries: &mut BTreeMap<u32, XrefEntry>,
|
||||
odd: &mut Vec<u32>,
|
||||
counted: &mut usize,
|
||||
log: &mut Vec<String>,
|
||||
) -> Result<(Dict, bool), CosError> {
|
||||
#[cfg(test)]
|
||||
@@ -479,6 +517,7 @@ impl Document {
|
||||
}
|
||||
let start = shifted;
|
||||
for i in 0..count {
|
||||
*counted = counted.saturating_add(1);
|
||||
lx.skip_ws();
|
||||
let o = lx.token();
|
||||
lx.skip_ws();
|
||||
@@ -549,6 +588,7 @@ impl Document {
|
||||
}
|
||||
for i in 0..*count {
|
||||
let Some(r) = rows.next() else { break };
|
||||
*counted = counted.saturating_add(1);
|
||||
let t = field(r, 0, w[0], 1);
|
||||
let a = field(r, w[0], w[1], 0);
|
||||
let b = field(r, w[0] + w[1], w[2], 0);
|
||||
|
||||
@@ -8,7 +8,10 @@ thread_local! {
|
||||
/// retries a parse several ways, and each retry would resolve the same lengths again
|
||||
/// (exponential in the chain of streams whose lengths point at further streams). Each is
|
||||
/// kept with whether resolving it read unsaved edits.
|
||||
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, (Option<i64>, bool)>> = std::cell::RefCell::new(HashMap::new());
|
||||
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, Length>> = std::cell::RefCell::new(HashMap::new());
|
||||
/// What the loads running on this thread have read ([`ReadFrame`]): how many frames are
|
||||
/// open, the object numbers read inside them, and how often that list was full.
|
||||
static READS: std::cell::RefCell<(u32, Vec<u32>, u64)> = const { std::cell::RefCell::new((0, Vec::new(), 0)) };
|
||||
/// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`).
|
||||
static OVERLAY_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
/// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length`
|
||||
@@ -59,6 +62,94 @@ impl Drop for ScanLengths {
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved `/Length` in [`LENGTHS`]: its value, whether resolving it read unsaved edits, and
|
||||
/// what it read.
|
||||
pub(super) type Length = (Option<i64>, bool, Reads);
|
||||
|
||||
/// The other objects a cached load read: an object stream for a member, an indirect `/Length`,
|
||||
/// and what those read in turn. Clones share the caches, so a document whose edits include one of
|
||||
/// them reads the object again instead (`Document::try_get`); `None` is a load that read more
|
||||
/// than [`Reads::MAX`], which such a document always reads again.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Reads(Option<Arc<[u32]>>);
|
||||
|
||||
impl Reads {
|
||||
/// Objects one entry keeps: a member's stream and a length or two, and what they read.
|
||||
const MAX: usize = 64;
|
||||
/// Object numbers the open frames keep between them.
|
||||
const HELD: usize = 4096;
|
||||
|
||||
/// Whether `overlay` (a document's edits) holds any of these objects.
|
||||
pub(super) fn touch(&self, overlay: &BTreeMap<u32, Slot>) -> bool {
|
||||
!overlay.is_empty() && self.0.as_ref().is_none_or(|nums| nums.iter().any(|n| overlay.contains_key(n)))
|
||||
}
|
||||
|
||||
fn bytes(&self) -> usize {
|
||||
self.0.as_ref().map_or(0, |nums| nums.len().saturating_mul(std::mem::size_of::<u32>()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts `num` as read by the loads open on this thread, with what loading it read (`reads`).
|
||||
pub(super) fn note_read(num: u32, reads: Option<&Reads>) {
|
||||
READS.with(|r| {
|
||||
let (depth, held, full) = &mut *r.borrow_mut();
|
||||
if *depth == 0 {
|
||||
return;
|
||||
}
|
||||
let more = reads.map_or(Some(&[][..]), |r| r.0.as_deref());
|
||||
match more {
|
||||
Some(more) if held.len().saturating_add(more.len()) < Reads::HELD => {
|
||||
held.push(num);
|
||||
held.extend_from_slice(more);
|
||||
}
|
||||
_ => *full = full.wrapping_add(1),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Collects what one load reads, for [`Reads`]; frames nest, and each read counts for every
|
||||
/// frame open around it.
|
||||
pub(super) struct ReadFrame {
|
||||
start: usize,
|
||||
full: u64,
|
||||
}
|
||||
|
||||
impl ReadFrame {
|
||||
pub(super) fn enter() -> Self {
|
||||
READS.with(|r| {
|
||||
let (depth, held, full) = &mut *r.borrow_mut();
|
||||
*depth = depth.saturating_add(1);
|
||||
ReadFrame { start: held.len(), full: *full }
|
||||
})
|
||||
}
|
||||
|
||||
/// What was read since the frame opened.
|
||||
pub(super) fn reads(&self) -> Reads {
|
||||
READS.with(|r| {
|
||||
let (_, held, full) = &*r.borrow();
|
||||
if *full != self.full {
|
||||
return Reads(None);
|
||||
}
|
||||
let mut nums = held.get(self.start..).unwrap_or_default().to_vec();
|
||||
nums.sort_unstable();
|
||||
nums.dedup();
|
||||
Reads((nums.len() <= Reads::MAX).then(|| Arc::from(nums)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReadFrame {
|
||||
fn drop(&mut self) {
|
||||
READS.with(|r| {
|
||||
let (depth, held, _) = &mut *r.borrow_mut();
|
||||
*depth = depth.saturating_sub(1);
|
||||
if *depth == 0 {
|
||||
held.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsaved edits read so far on this thread: a load during which this changes depended on one.
|
||||
pub(super) fn overlay_reads() -> u64 {
|
||||
OVERLAY_READS.with(std::cell::Cell::get)
|
||||
@@ -122,6 +213,8 @@ pub(super) struct ObjStm {
|
||||
/// or offset is unusable ([`objstm_member`]).
|
||||
pub(super) index: Vec<Option<(u32, usize)>>,
|
||||
pub(super) data: Arc<Vec<u8>>,
|
||||
/// What parsing it read: the stream itself and its `/Length`, `/DecodeParms` and so on.
|
||||
pub(super) reads: Reads,
|
||||
}
|
||||
|
||||
/// Where member `num` of an object stream starts: its `offset` past `/First` (`first`), when the
|
||||
@@ -317,13 +410,14 @@ thread_local! {
|
||||
/// (#5873).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ObjectCache {
|
||||
entries: HashMap<u32, (Loaded, usize)>,
|
||||
entries: HashMap<u32, (Loaded, usize, Reads)>,
|
||||
owned: usize,
|
||||
}
|
||||
|
||||
impl ObjectCache {
|
||||
pub(crate) fn get(&self, num: &u32) -> Option<&Loaded> {
|
||||
self.entries.get(num).map(|(object, _)| object)
|
||||
/// What loading `num` gave, and what that load read.
|
||||
pub(super) fn get(&self, num: &u32) -> Option<(&Loaded, &Reads)> {
|
||||
self.entries.get(num).map(|(object, _, reads)| (object, reads))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -331,9 +425,10 @@ impl ObjectCache {
|
||||
self.entries.contains_key(num)
|
||||
}
|
||||
|
||||
/// Keep what loading `num` gave, which holds `owned` bytes ([`loaded_bytes`]).
|
||||
pub(crate) fn insert(&mut self, num: u32, object: Loaded, owned: usize) {
|
||||
if let Some((_, before)) = self.entries.insert(num, (object, owned)) {
|
||||
/// Keep what loading `num` gave, which holds `owned` bytes ([`loaded_bytes`]), and what it read.
|
||||
pub(super) fn insert(&mut self, num: u32, object: Loaded, owned: usize, reads: Reads) {
|
||||
let owned = owned.saturating_add(reads.bytes());
|
||||
if let Some((_, before, _)) = self.entries.insert(num, (object, owned, reads)) {
|
||||
self.owned = self.owned.saturating_sub(before);
|
||||
}
|
||||
self.owned = self.owned.saturating_add(owned);
|
||||
@@ -358,19 +453,14 @@ impl ObjectCache {
|
||||
self.owned = 0;
|
||||
}
|
||||
|
||||
/// Drop the cached failures `stale` picks. A failure holds no bytes, so the total stands.
|
||||
pub(crate) fn forget_failures(&mut self, mut stale: impl FnMut(&u32) -> bool) {
|
||||
self.entries.retain(|num, (loaded, _)| loaded.is_ok() || !stale(num));
|
||||
}
|
||||
|
||||
pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) {
|
||||
self.entries.retain(|num, _| keep(num));
|
||||
self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned));
|
||||
self.owned = self.entries.values().fold(0usize, |n, (_, owned, _)| n.saturating_add(*owned));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn values(&self) -> impl Iterator<Item = &Arc<Object>> {
|
||||
self.entries.values().filter_map(|(object, _)| object.as_ref().ok())
|
||||
self.entries.values().filter_map(|(object, _, _)| object.as_ref().ok())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
/// What [`crate::reachable_objects`] gives is what a full save keeps: not an object nothing refers to, nor one
|
||||
/// a reference only dangles at, and a ring of references ends.
|
||||
#[test]
|
||||
fn what_is_reachable_is_what_a_full_save_keeps() {
|
||||
let mut doc = Document::new_empty();
|
||||
let root = doc.root().unwrap();
|
||||
let kept = doc.add(Object::Int(7));
|
||||
let dangling = ObjRef::new(4_000, 0);
|
||||
let list = doc.add(Object::Null);
|
||||
let ring = doc.add(Object::Array(vec![Object::Ref(list)]));
|
||||
doc.set(list, Object::Array(vec![Object::Ref(kept), Object::Ref(ring), Object::Ref(dangling)]));
|
||||
let orphan = doc.add(Object::Int(8));
|
||||
doc.update_dict(root, |d| d.set(b"Held".to_vec(), Object::Ref(list))).unwrap();
|
||||
let found = crate::reachable_objects(&doc).unwrap();
|
||||
assert!([root, kept, list, ring].iter().all(|r| found.contains(&r.num)));
|
||||
assert!(!found.contains(&orphan.num) && !found.contains(&dangling.num));
|
||||
let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
|
||||
let holds = |n: i64| saved.scan_objects().any(|(_, o)| matches!(*o, Object::Int(v) if v == n));
|
||||
assert!(holds(7) && !holds(8));
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// How many times `Document::scan_for` read the file on this thread.
|
||||
pub(super) static FILE_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
@@ -862,6 +883,179 @@ fn an_edited_object_stream_leaves_other_clones_members_alone() {
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone");
|
||||
}
|
||||
|
||||
/// A member another clone read from the file's object stream is not what a clone that replaced
|
||||
/// the stream reads, nor is one the document read itself before replacing it.
|
||||
#[test]
|
||||
fn a_replaced_object_stream_hides_the_members_read_before() {
|
||||
let replacement = || {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Type".to_vec(), Object::name("ObjStm"));
|
||||
dict.set(b"N".to_vec(), Object::Int(1));
|
||||
dict.set(b"First".to_vec(), Object::Int(4));
|
||||
crate::Stream::from_raw(dict, b"3 0 << /Hidden (no) >>".to_vec())
|
||||
};
|
||||
let file = Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0));
|
||||
let doc = Document::open(file.clone()).unwrap();
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
|
||||
let mut replaced = doc.clone();
|
||||
replaced.set(ObjRef::new(4, 0), replacement());
|
||||
assert_eq!(text_of(&replaced, 3, b"Hidden").as_deref(), Some("no"), "another clone's read of the file's stream");
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone");
|
||||
let mut doc = Document::open(file.clone()).unwrap();
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
|
||||
doc.set(ObjRef::new(4, 0), replacement());
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("no"), "the document's own read before the edit");
|
||||
let doc = Document::open(file).unwrap();
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
|
||||
let mut freed = doc.clone();
|
||||
freed.free(ObjRef::new(4, 0));
|
||||
assert_eq!(freed.try_get(3), Err(CosError::MissingObject(4)), "another clone's read before the free");
|
||||
}
|
||||
|
||||
/// An object stream another clone framed by the file's indirect `/Length` is not what a clone
|
||||
/// that edited that length reads.
|
||||
#[test]
|
||||
fn an_edited_object_stream_length_hides_the_members_read_before() {
|
||||
let file = Arc::new(object_stream_file_with("3 0 ", "<< /Hidden (cut endstream here) >>", 0, true));
|
||||
let doc = Document::open(file).unwrap();
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"));
|
||||
let mut edited = doc.clone();
|
||||
edited.set(ObjRef::new(6, 0), Object::Int("3 0 << /Hidden (cut".len() as i64));
|
||||
assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "another clone's framing of the stream");
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone");
|
||||
}
|
||||
|
||||
/// A stream another clone read by the file's indirect `/Length` is not what a clone that edited
|
||||
/// that length reads.
|
||||
#[test]
|
||||
fn an_edited_stream_length_hides_the_stream_read_before() {
|
||||
let bodies = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [] /Count 0 >>",
|
||||
"<< /Length 4 0 R >>\nstream\nshort\nendstream long\nendstream",
|
||||
"20",
|
||||
];
|
||||
let mut out = b"%PDF-1.4\n".to_vec();
|
||||
let mut table = String::from("xref\n0 5\n0000000000 65535 f \n");
|
||||
for (n, body) in bodies.iter().enumerate() {
|
||||
table.push_str(&format!("{:010} 00000 n \n", out.len()));
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", n + 1).as_bytes());
|
||||
}
|
||||
let start = out.len();
|
||||
out.extend_from_slice(format!("{table}trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n").as_bytes());
|
||||
let data = |doc: &Document| match &*doc.get(ObjRef::new(3, 0)) {
|
||||
Object::Stream(s) => s.raw.to_vec(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let doc = Document::open(Arc::new(out)).unwrap();
|
||||
assert_eq!(data(&doc), b"short\nendstream long");
|
||||
let mut edited = doc.clone();
|
||||
edited.set(ObjRef::new(4, 0), Object::Int(5));
|
||||
assert_eq!(data(&edited), b"short", "another clone's read by the file's length");
|
||||
assert_eq!(data(&doc), b"short\nendstream long", "the edited length reached another clone");
|
||||
}
|
||||
|
||||
/// A file whose object 3 sits in an object stream PNG-predicted by `/DecodeParms 6 1 R`, a
|
||||
/// generation the file doesn't have, and the parameters that make it decode.
|
||||
fn predicted_object_stream() -> (Vec<u8>, Dict) {
|
||||
let members = b"3 0 << /Hidden (yes) >>";
|
||||
// PNG Sub, one row.
|
||||
let mut rows = vec![1u8];
|
||||
let mut before = 0u8;
|
||||
for b in members {
|
||||
rows.push(b.wrapping_sub(before));
|
||||
before = *b;
|
||||
}
|
||||
let coded = pdfcraft_filters::encode_flate(&rows);
|
||||
let mut out = b"%PDF-1.5\n".to_vec();
|
||||
let mut offs = [0usize; 7];
|
||||
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
|
||||
offs[n] = out.len();
|
||||
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
|
||||
let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms 6 1 R /Length {} >>\nstream\n", coded.len()).into_bytes();
|
||||
stm.extend_from_slice(&coded);
|
||||
stm.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, 4, &stm);
|
||||
let data = [2u8, 0, 4, 0];
|
||||
let mut xs = format!("<< /Type /XRef /Size 7 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
xs.extend_from_slice(&data);
|
||||
xs.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, 5, &xs);
|
||||
obj(&mut out, 6, b"null");
|
||||
let table = out.len();
|
||||
out.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
|
||||
for (n, off) in offs.iter().enumerate().skip(1) {
|
||||
let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") };
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size 7 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(11));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(members.len() as i64));
|
||||
(out, parms)
|
||||
}
|
||||
|
||||
fn hidden(doc: &Document) -> Option<Vec<u8>> {
|
||||
doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Hidden")).and_then(Object::as_string).map(|s| s.bytes.clone())
|
||||
}
|
||||
|
||||
/// An object stream whose `/DecodeParms` names a generation the file doesn't have decodes without
|
||||
/// them; a clone that sets that generation decodes with them, not by the stream another clone read.
|
||||
#[test]
|
||||
fn parameters_an_edit_makes_match_hide_the_object_stream_read_before() {
|
||||
let (file, parms) = predicted_object_stream();
|
||||
let doc = Document::open(Arc::new(file)).unwrap();
|
||||
assert_eq!(hidden(&doc), None, "no object 6 1: no predictor undone");
|
||||
let mut edited = doc.clone();
|
||||
edited.set(ObjRef::new(6, 1), Object::Dict(parms));
|
||||
assert_eq!(hidden(&edited), Some(b"yes".to_vec()), "another clone's stream, decoded without them");
|
||||
}
|
||||
|
||||
/// The other way round: an object stream a clone decoded by its edited parameters stays that
|
||||
/// clone's, so the document it was cloned from still decodes by the file's.
|
||||
#[test]
|
||||
fn an_object_stream_decoded_by_edited_parameters_is_not_shared() {
|
||||
let (file, parms) = predicted_object_stream();
|
||||
let doc = Document::open(Arc::new(file)).unwrap();
|
||||
let mut edited = doc.clone();
|
||||
edited.set(ObjRef::new(6, 1), Object::Dict(parms));
|
||||
assert_eq!(hidden(&edited), Some(b"yes".to_vec()));
|
||||
assert_eq!(hidden(&doc), None, "the clone's decoding, read from the shared streams");
|
||||
}
|
||||
|
||||
/// What a load read is kept only up to a bound; past it the load counts as having read every
|
||||
/// object, so a document with any edit reads it again.
|
||||
#[test]
|
||||
fn a_load_that_read_too_much_counts_as_reading_everything() {
|
||||
use super::state::{ReadFrame, note_read};
|
||||
let mut overlay = BTreeMap::new();
|
||||
overlay.insert(999_999, Slot::Freed(1));
|
||||
let frame = ReadFrame::enter();
|
||||
for n in 0..8 {
|
||||
note_read(n, None);
|
||||
}
|
||||
assert!(!frame.reads().touch(&overlay), "a few reads, none edited");
|
||||
assert!(!frame.reads().touch(&BTreeMap::new()), "nothing is edited");
|
||||
for n in 8..100 {
|
||||
note_read(n, None);
|
||||
}
|
||||
assert!(frame.reads().touch(&overlay), "more objects than an entry keeps");
|
||||
drop(frame);
|
||||
let outer = ReadFrame::enter();
|
||||
let inner = ReadFrame::enter();
|
||||
for n in 0..5_000 {
|
||||
note_read(n % 4, None);
|
||||
}
|
||||
assert!(inner.reads().touch(&overlay), "more reads than the thread keeps");
|
||||
drop(inner);
|
||||
assert!(outer.reads().touch(&overlay), "an outer frame missed them too");
|
||||
}
|
||||
|
||||
/// A member that failed to load from the file's object stream is kept as a failure, but replacing
|
||||
/// or freeing that stream forgets it: the member then reads from the edit.
|
||||
#[test]
|
||||
@@ -1479,7 +1673,7 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() {
|
||||
// cleared after the next read, including a failed one.
|
||||
let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1]));
|
||||
let size = held_bytes(&owned, &reader.document.data, 0);
|
||||
reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size);
|
||||
reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size, Default::default());
|
||||
assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null);
|
||||
assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted");
|
||||
}
|
||||
@@ -2064,6 +2258,176 @@ fn a_decompression_bomb_in_an_xref_stream_is_refused_and_the_file_reconstructed(
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log());
|
||||
}
|
||||
|
||||
/// A chain of cross-reference streams whose sections each stay well under the per-section
|
||||
/// limit but together list more than 8,388,607 rows (#5082). Too many rows for the compact
|
||||
/// reader, the chain was merged whole; it is now refused and the file rebuilt from object
|
||||
/// headers. The bulk rows are set aside (in use at offset 0), so the test holds a few bytes a row.
|
||||
#[test]
|
||||
fn a_cross_reference_chain_past_the_total_cap_is_refused_and_the_file_reconstructed() {
|
||||
const BULK: usize = 4_200_000;
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for body in
|
||||
["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"]
|
||||
{
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
|
||||
}
|
||||
// Objects 4 and 5: /W [1 0 0] rows of type 1 at offset 0, each a set-aside entry.
|
||||
let bulk = pdfcraft_filters::encode_flate(&vec![1u8; BULK]);
|
||||
let size = 16 + 2 * BULK;
|
||||
let mut prev = None;
|
||||
for (num, start) in [(4, 16 + BULK), (5, 16)] {
|
||||
offsets.push(bytes.len());
|
||||
let prev_key = prev.map(|p| format!(" /Prev {p}")).unwrap_or_default();
|
||||
bytes.extend_from_slice(
|
||||
format!(
|
||||
"{num} 0 obj\n<< /Type /XRef /Size {size} /W [1 0 0] /Index [{start} {BULK}]{prev_key} /Filter /FlateDecode /Length {} >>\nstream\n",
|
||||
bulk.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
bytes.extend_from_slice(&bulk);
|
||||
bytes.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
prev = offsets.last().copied();
|
||||
}
|
||||
// Object 6, the newest section: objects 0 to 6 at their offsets.
|
||||
let x = bytes.len();
|
||||
offsets.push(x);
|
||||
let mut rows = vec![0u8, 0, 0, 0, 0, 255];
|
||||
for o in &offsets {
|
||||
rows.push(1);
|
||||
rows.extend_from_slice(&u32::try_from(*o).unwrap().to_be_bytes());
|
||||
rows.push(0);
|
||||
}
|
||||
bytes.extend_from_slice(
|
||||
format!("6 0 obj\n<< /Type /XRef /Size {size} /Root 1 0 R /W [1 4 1] /Index [0 7] /Prev {} /Length {} >>\nstream\n", offsets[4], rows.len())
|
||||
.as_bytes(),
|
||||
);
|
||||
bytes.extend_from_slice(&rows);
|
||||
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes());
|
||||
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let log = doc.repair_log();
|
||||
assert!(
|
||||
log.iter().any(|l| l.contains("cross-reference chain lists more than 8388607 entries") && l.contains("rebuilding from object headers")),
|
||||
"{log:?}"
|
||||
);
|
||||
assert!(log.iter().any(|l| l.starts_with("rebuilt cross-reference index")), "{log:?}");
|
||||
assert!(doc.revisions().is_empty());
|
||||
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
|
||||
let page = doc.try_get(3).unwrap();
|
||||
assert_eq!(page.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..]));
|
||||
// The chain read itself is refused at the second bulk section, which each decode within its own limit.
|
||||
let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized(&mut Vec::new()) else { panic!("the chain must be refused") };
|
||||
assert_eq!(offset, offsets[3]);
|
||||
assert!(detail.contains("more than 8388607 entries"), "{detail}");
|
||||
}
|
||||
|
||||
/// Three classic tables listing 2, 1 and 1 rows, newest first: objects 1 and 2, then object 3
|
||||
/// (or object 1 again when `overlap`), then object 4.
|
||||
fn three_table_chain(overlap: bool) -> (Vec<u8>, usize) {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (num, body) in
|
||||
["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 3 >>", "<< /Value 4 >>"].iter().enumerate()
|
||||
{
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", num + 1).as_bytes());
|
||||
}
|
||||
let older = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n4 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R >>\n", offsets[3]).as_bytes());
|
||||
let middle = bytes.len();
|
||||
let middle_num = if overlap { 1 } else { 3 };
|
||||
bytes.extend_from_slice(
|
||||
format!("xref\n{middle_num} 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R /Prev {older} >>\n", offsets[middle_num - 1]).as_bytes(),
|
||||
);
|
||||
let newer = bytes.len();
|
||||
bytes.extend_from_slice(
|
||||
format!(
|
||||
"xref\n1 1\n{:010} 00000 n \n2 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R /Prev {middle} >>\nstartxref\n{newer}\n%%EOF\n",
|
||||
offsets[0], offsets[1],
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
(bytes, older)
|
||||
}
|
||||
|
||||
/// The chain's row limit counts every row merged (#5082): four rows over three sections pass a
|
||||
/// limit of 4 and are refused at the oldest section by a limit of 3, both when the sections
|
||||
/// list different objects and when one relists an object a newer section has (a map of three
|
||||
/// entries, but four rows merged).
|
||||
#[test]
|
||||
fn the_chain_row_limit_counts_disjoint_and_relisted_rows() {
|
||||
for overlap in [false, true] {
|
||||
let (bytes, older) = three_table_chain(overlap);
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let (entries, ..) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 4).unwrap();
|
||||
assert_eq!(entries.len(), if overlap { 3 } else { 4 });
|
||||
let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 3) else {
|
||||
panic!("four rows must be refused by a limit of 3 (overlap: {overlap})")
|
||||
};
|
||||
assert_eq!(offset, older);
|
||||
assert!(detail.contains("more than 3 entries"), "{detail}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows set aside count against the chain's row limit (#5082): a hybrid file whose table puts
|
||||
/// object 3 at an offset while its /XRefStm puts it in an object stream merges six rows and sets
|
||||
/// one aside, so a limit of 7 passes and a limit of 6 refuses it.
|
||||
#[test]
|
||||
fn the_chain_row_limit_counts_hybrid_rows_set_aside() {
|
||||
let mut bytes = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
|
||||
// Object 3's row in the table (`xref\n0 6\n`, 20-byte rows): free, now in use at offset 9.
|
||||
let row = find(&bytes, b"xref\n0 6\n", 0, bytes.len()).unwrap() + 9 + 3 * 20;
|
||||
assert_eq!(&bytes[row..row + 20], b"0000000000 65535 f \n");
|
||||
bytes[row..row + 20].copy_from_slice(b"0000000009 00000 n \n");
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let (entries, _, _, oddities, _) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 7).unwrap();
|
||||
assert_eq!(entries.len(), 6);
|
||||
assert_eq!(oddities.iter().map(|(_, num)| *num).collect::<Vec<_>>(), vec![3]);
|
||||
let Err(CosError::Syntax { detail, .. }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 6) else {
|
||||
panic!("six rows and one set aside must be refused by a limit of 6")
|
||||
};
|
||||
assert!(detail.contains("more than 6 entries"), "{detail}");
|
||||
}
|
||||
|
||||
/// A cross-reference stream whose `/Index [0 4 0 4]` lists objects 0 to 3 twice, the same rows
|
||||
/// both times: eight rows read, four entries (#5082). The chain's row limit counts the rows
|
||||
/// read, before deduplication, so a limit of 8 passes and a limit of 7 refuses it.
|
||||
#[test]
|
||||
fn the_chain_row_limit_counts_rows_an_xref_stream_index_repeats() {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for body in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"] {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
|
||||
}
|
||||
let x = bytes.len();
|
||||
offsets.push(x);
|
||||
let mut once = vec![0u8, 0, 0, 0, 0, 255];
|
||||
for o in &offsets {
|
||||
once.push(1);
|
||||
once.extend_from_slice(&u32::try_from(*o).unwrap().to_be_bytes());
|
||||
once.push(0);
|
||||
}
|
||||
let rows = [once.clone(), once].concat();
|
||||
bytes.extend_from_slice(
|
||||
format!("3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 1] /Index [0 4 0 4] /Length {} >>\nstream\n", rows.len()).as_bytes(),
|
||||
);
|
||||
bytes.extend_from_slice(&rows);
|
||||
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes());
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let (entries, _, _, oddities, _) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 8).unwrap();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert!(oddities.is_empty(), "{oddities:?}");
|
||||
let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 7) else {
|
||||
panic!("eight rows listing four objects must be refused by a limit of 7")
|
||||
};
|
||||
assert_eq!(offset, x);
|
||||
assert!(detail.contains("more than 7 entries"), "{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
|
||||
// From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream`
|
||||
@@ -2217,10 +2581,10 @@ fn the_object_cache_keeps_its_total_as_objects_come_and_go() {
|
||||
let two = Arc::new(Object::Array(vec![Object::Int(1); 10]));
|
||||
let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0));
|
||||
let mut cache = super::state::ObjectCache::default();
|
||||
cache.insert(1, Ok(one.clone()), a);
|
||||
cache.insert(2, Ok(two), b);
|
||||
cache.insert(1, Ok(one.clone()), a, Default::default());
|
||||
cache.insert(2, Ok(two), b, Default::default());
|
||||
assert_eq!(cache.owned(), a + b);
|
||||
cache.insert(2, Ok(one), a);
|
||||
cache.insert(2, Ok(one), a, Default::default());
|
||||
assert_eq!(cache.owned(), a + a, "a replaced object no longer counts");
|
||||
cache.retain(|n| *n == 1);
|
||||
assert_eq!((cache.len(), cache.owned()), (1, a));
|
||||
|
||||
@@ -24,7 +24,7 @@ mod writer;
|
||||
|
||||
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS};
|
||||
pub use bytes::Bytes;
|
||||
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use document::{DECODE_PARMS_KEYS, Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars};
|
||||
pub use parser::{Lexer, is_whitespace, parse_indirect};
|
||||
pub use pdfcraft_crypt::{
|
||||
|
||||
@@ -351,10 +351,12 @@ impl Stream {
|
||||
}
|
||||
|
||||
/// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
|
||||
/// decoded before the damage is returned). Streams with image codecs fail with
|
||||
/// decoded before the damage is returned). Decoding past
|
||||
/// [`crate::budget::MAX_PAGE_DECODED`] (256 MiB), the most the renderer reads from one
|
||||
/// stream, is an error. Streams with image codecs fail with
|
||||
/// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
|
||||
pub fn decoded(&self) -> Result<Vec<u8>, CosError> {
|
||||
self.decoded_within(MAX_DECODED)
|
||||
self.decoded_within(crate::budget::MAX_PAGE_DECODED)
|
||||
}
|
||||
|
||||
/// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than
|
||||
@@ -382,6 +384,26 @@ impl Stream {
|
||||
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as
|
||||
/// [`Stream::decoded_within`] holds it, while `max` bounds the stages together
|
||||
/// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to
|
||||
/// both.
|
||||
pub(crate) fn decoded_within_each_counted(
|
||||
&self,
|
||||
each: usize,
|
||||
max: usize,
|
||||
) -> Result<(Vec<u8>, bool, usize), (pdfcraft_filters::FilterError, usize)> {
|
||||
let mut damaged = false;
|
||||
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
|
||||
pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| {
|
||||
damaged = partial;
|
||||
(v, spent)
|
||||
})
|
||||
};
|
||||
let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max };
|
||||
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
|
||||
/// document's images ([`pdfcraft_filters::decode_tolerant_within`]).
|
||||
pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
|
||||
@@ -724,6 +746,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_decoding_is_bounded_by_the_callers_remaining_allowance() {
|
||||
let stream = Stream::flate(Dict::new(), &vec![0u8; 1 << 20]);
|
||||
assert!(matches!(stream.decoded_strict_within(1000), Err(CosError::Filter(_))));
|
||||
assert_eq!(stream.decoded_strict_within(1 << 20).map(|v| v.len()).ok(), Some(1 << 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() {
|
||||
let mut dict = Dict::new();
|
||||
@@ -780,6 +809,13 @@ mod tests {
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_stops_at_renderer_cap_of_256_mib() {
|
||||
let data = vec![0u8; (256 << 20) + 1];
|
||||
let stream = Stream::flate(Dict::new(), &data);
|
||||
assert!(matches!(stream.decoded(), Err(CosError::Filter(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_filter_decodes_only_the_outer_wrapper() {
|
||||
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
|
||||
|
||||
@@ -24,6 +24,11 @@ thiserror = { workspace = true }
|
||||
# Already in the graph through lopdf; used only to read plain-text files (decode_text).
|
||||
encoding_rs = "0.8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# Already in the graph (pdfcraft-platform, ui-egui); stops a timed-out LibreOffice's whole process
|
||||
# group, soffice.bin included, without unsafe code.
|
||||
rustix = { version = "1", features = ["process"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# An independent CCITT decoder, to check Create PDF's Group 4 images.
|
||||
hayro-ccitt.workspace = true
|
||||
|
||||
@@ -28,6 +28,30 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
|
||||
|
||||
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
|
||||
|
||||
Office documents (`office.rs`) are the exception: PdfCraft doesn't read Word, Excel, PowerPoint,
|
||||
OpenDocument or RTF itself. `is_office(name, bytes)` recognises them (an `OFFICE_EXTENSIONS`
|
||||
name and an OLE, ZIP or RTF signature; `source_kind` reports `SourceKind::Office`), and
|
||||
`office_to_pdf(name, bytes)` returns PDF bytes made by an installed LibreOffice, run headless as
|
||||
a separate process: a private temporary folder (owner-only on Unix; the document is always
|
||||
`document.<ext>` there, never its own name), a fresh profile with macros disabled, untrusted
|
||||
links blocked and Writer and Calc set never to update linked content (sections, files, external
|
||||
data) on load, no console window on Windows, and a stop after `TIMEOUT` (120 s) or as soon as the
|
||||
output grows past 512 MB (the whole process tree is stopped, not only `soffice`). LibreOffice is
|
||||
found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders (`libreoffice()`).
|
||||
A snap-installed LibreOffice is not among those folders: its private `/tmp` cannot see the work
|
||||
folder. Where `/snap/bin` is on the `PATH` it can still be found there, so point
|
||||
`PDFCRAFT_SOFFICE` at a non-snap install if conversions fail. It takes seconds, so callers keep it
|
||||
off the frame thread. On the web it returns an error.
|
||||
|
||||
Impress has no setting for linked content, and the profile settings are LibreOffice's own:
|
||||
PdfCraft doesn't sandbox the process's network access. Converting test documents whose linked
|
||||
sections, pictures and external cell references point at a local listener made no connection,
|
||||
with or without the Writer and Calc settings (LibreOffice 26.8.1, Windows, 2026-10-26), so the settings are a
|
||||
second line of defence rather than a measured fix.
|
||||
|
||||
`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS` and `OFFICE_EXTENSIONS` are what the app's file pickers
|
||||
offer, so detection and pickers agree.
|
||||
|
||||
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
|
||||
JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind
|
||||
other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! pdfcraft-create — create PDFs from nothing, images or text (L4). See the README.
|
||||
//! pdfcraft-create — create PDFs from nothing, images, text or (through LibreOffice) Office documents (L4). See the README.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -8,6 +8,10 @@ mod extract;
|
||||
mod text_font;
|
||||
mod upright_image;
|
||||
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within};
|
||||
mod office;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use office::libreoffice;
|
||||
pub use office::{OFFICE_EXTENSIONS, is_office, office_to_pdf};
|
||||
use pdfcraft_fonts::wrap_with;
|
||||
use text_font::TextFont;
|
||||
|
||||
@@ -17,6 +21,9 @@ pub enum CreateError {
|
||||
Image(String, String),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
/// An Office document LibreOffice couldn't convert, or LibreOffice isn't installed.
|
||||
#[error("{0}")]
|
||||
Office(String),
|
||||
}
|
||||
|
||||
/// US Letter in points.
|
||||
@@ -757,9 +764,18 @@ pub enum SourceKind {
|
||||
Pdf,
|
||||
Image,
|
||||
Text,
|
||||
/// A Word, Excel, PowerPoint or OpenDocument file, converted through LibreOffice.
|
||||
Office,
|
||||
}
|
||||
|
||||
/// File extensions Create converts to PDF (images and plain text).
|
||||
/// File extensions of the images Create converts to PDF.
|
||||
pub const IMAGE_EXTENSIONS: [&str; 11] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx"];
|
||||
|
||||
/// File extensions of the plain-text files Create converts to PDF.
|
||||
pub const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"];
|
||||
|
||||
/// File extensions Create converts to PDF itself: [`IMAGE_EXTENSIONS`] and [`TEXT_EXTENSIONS`]
|
||||
/// (Office documents go through LibreOffice, [`OFFICE_EXTENSIONS`]).
|
||||
pub const CONVERTIBLE: [&str; 13] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx", "txt", "text"];
|
||||
|
||||
/// A PCX file: its manufacturer byte, a version (1 was never used), run-length coding and 1 or 8
|
||||
@@ -792,8 +808,8 @@ fn is_image(bytes: &[u8]) -> bool {
|
||||
&& bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124)))
|
||||
}
|
||||
|
||||
/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or
|
||||
/// `.text` file). `None` for anything else.
|
||||
/// Whether `bytes` named `name` is a PDF, an image Create can embed, plain text (a `.txt` or
|
||||
/// `.text` file) or an Office document ([`is_office`]). `None` for anything else.
|
||||
pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
|
||||
let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default();
|
||||
if head.windows(5).any(|w| w == b"%PDF-") {
|
||||
@@ -802,6 +818,9 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
|
||||
if is_image(bytes) {
|
||||
return Some(SourceKind::Image);
|
||||
}
|
||||
if is_office(name, bytes) {
|
||||
return Some(SourceKind::Office);
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
(lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
//! Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) to PDF through an installed
|
||||
//! LibreOffice, run as a separate process: the "LibreOffice sidecar" of
|
||||
//! `docs/file-format-parity.md`. PdfCraft never parses these formats itself.
|
||||
//!
|
||||
//! Detection ([`is_office`]) is pure and works everywhere. Conversion ([`office_to_pdf`]) runs
|
||||
//! only on desktop builds: each run gets its own temporary folder and a fresh LibreOffice
|
||||
//! profile with macros and untrusted links turned off, and is stopped after [`TIMEOUT`].
|
||||
|
||||
use crate::CreateError;
|
||||
|
||||
/// File extensions of the Office documents converted through LibreOffice.
|
||||
pub const OFFICE_EXTENSIONS: &[&str] = &[
|
||||
// Word and Writer
|
||||
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", //
|
||||
// Excel and Calc
|
||||
"xls", "xlsx", "xlsm", "xlt", "xltx", "xltm", "ods", "ots", //
|
||||
// PowerPoint and Impress
|
||||
"ppt", "pptx", "pptm", "pot", "potx", "potm", "pps", "ppsx", "ppsm", "odp", "otp",
|
||||
];
|
||||
|
||||
/// The OLE compound-file signature of Office 97–2003 files (`.doc`, `.xls`, `.ppt`).
|
||||
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
/// The longest a conversion may run before LibreOffice is stopped. A fresh profile costs a
|
||||
/// few seconds on its own; large spreadsheets take longer.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
/// The largest PDF read back from LibreOffice.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
const MAX_PDF: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// `name`'s extension as it appears in [`OFFICE_EXTENSIONS`], if it is one.
|
||||
fn office_extension(name: &str) -> Option<&'static str> {
|
||||
let (_, ext) = name.rsplit_once('.')?;
|
||||
OFFICE_EXTENSIONS.iter().copied().find(|e| ext.eq_ignore_ascii_case(e))
|
||||
}
|
||||
|
||||
/// Whether `bytes` named `name` is an Office document: an Office extension, and the bytes start
|
||||
/// like one (an OLE compound file, a ZIP package as OOXML and OpenDocument are, or RTF).
|
||||
pub fn is_office(name: &str, bytes: &[u8]) -> bool {
|
||||
office_extension(name).is_some() && (bytes.starts_with(OLE) || bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"{\\rtf"))
|
||||
}
|
||||
|
||||
/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn office_to_pdf(name: &str, _bytes: &[u8]) -> Result<Vec<u8>, CreateError> {
|
||||
Err(CreateError::Office(format!("{name}: Office documents open only in the desktop app, which converts them with LibreOffice")))
|
||||
}
|
||||
|
||||
/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn office_to_pdf(name: &str, bytes: &[u8]) -> Result<Vec<u8>, CreateError> {
|
||||
let program = native::libreoffice().ok_or_else(|| {
|
||||
CreateError::Office(format!(
|
||||
"{name}: opening Word, Excel and PowerPoint files needs LibreOffice, which isn't installed (get it free from libreoffice.org)"
|
||||
))
|
||||
})?;
|
||||
native::convert_with(&program, name, bytes, TIMEOUT)
|
||||
}
|
||||
|
||||
/// Where LibreOffice is installed, if it is: `PDFCRAFT_SOFFICE`, then the `PATH`, then the
|
||||
/// usual install folders.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn libreoffice() -> Option<std::path::PathBuf> {
|
||||
native::libreoffice()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use super::{CreateError, MAX_PDF, office_extension};
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The program names LibreOffice installs on the `PATH`.
|
||||
#[cfg(windows)]
|
||||
const PATH_NAMES: &[&str] = &["soffice.exe"];
|
||||
#[cfg(not(windows))]
|
||||
const PATH_NAMES: &[&str] = &["soffice", "libreoffice"];
|
||||
|
||||
pub(super) fn libreoffice() -> Option<PathBuf> {
|
||||
let defaults = default_locations(&|key| std::env::var_os(key));
|
||||
candidates(std::env::var_os("PDFCRAFT_SOFFICE"), std::env::var_os("PATH"), defaults).into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Where to look, in order: the override, each `PATH` folder, then `defaults`.
|
||||
pub(super) fn candidates(override_path: Option<OsString>, path: Option<OsString>, defaults: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = override_path.filter(|p| !p.is_empty()).map(PathBuf::from).into_iter().collect();
|
||||
if let Some(path) = path {
|
||||
for dir in std::env::split_paths(&path) {
|
||||
out.extend(PATH_NAMES.iter().map(|name| dir.join(name)));
|
||||
}
|
||||
}
|
||||
out.extend(defaults);
|
||||
out
|
||||
}
|
||||
|
||||
/// The usual install locations, given a way to read environment variables.
|
||||
#[cfg(windows)]
|
||||
pub(super) fn default_locations(env: &dyn Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
|
||||
["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]
|
||||
.iter()
|
||||
.filter_map(|key| env(key))
|
||||
.map(|dir| PathBuf::from(dir).join("LibreOffice").join("program").join("soffice.exe"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The usual install locations, given a way to read environment variables.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn default_locations(env: &dyn Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
|
||||
let app = Path::new("LibreOffice.app/Contents/MacOS/soffice");
|
||||
let mut out = vec![Path::new("/Applications").join(app)];
|
||||
out.extend(env("HOME").map(|home| PathBuf::from(home).join("Applications").join(app)));
|
||||
out
|
||||
}
|
||||
|
||||
/// The usual install locations, given a way to read environment variables.
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
pub(super) fn default_locations(_env: &dyn Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
|
||||
[
|
||||
"/usr/bin/soffice",
|
||||
"/usr/bin/libreoffice",
|
||||
"/usr/local/bin/soffice",
|
||||
"/usr/local/bin/libreoffice",
|
||||
"/usr/lib/libreoffice/program/soffice",
|
||||
"/opt/libreoffice/program/soffice",
|
||||
]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The fresh profile's settings: no macros at all, no links to untrusted locations, and
|
||||
/// linked content in Writer and Calc documents (sections, files, external data) never
|
||||
/// updated on load, so a document can't make LibreOffice fetch from the network or a share.
|
||||
/// The two `Link` settings number "Never" differently (Writer.xcs: 0 Always, 1 On request,
|
||||
/// 2 Never; Calc.xcs: 0 Always, 1 Never, 2 On request). Impress has no such setting.
|
||||
pub(super) const PROFILE_SETTINGS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="DisableMacrosExecution" oor:op="fuse"><value>true</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="MacroSecurityLevel" oor:op="fuse"><value>3</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Link" oor:op="fuse"><value>2</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Office.Calc/Content/Update"><prop oor:name="Link" oor:op="fuse"><value>1</value></prop></item>
|
||||
</oor:items>
|
||||
"#;
|
||||
|
||||
/// A private temporary folder, removed (with everything in it) when dropped.
|
||||
pub(super) struct WorkDir(pub(super) PathBuf);
|
||||
|
||||
impl WorkDir {
|
||||
pub(super) fn new() -> std::io::Result<Self> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-office-{}-{n}-{nanos}", std::process::id()));
|
||||
// Not recursive: a folder that already exists is not ours. Owner-only on Unix, where the
|
||||
// temporary folder is shared: the document and its PDF are not for other users.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
builder.create(&dir)?;
|
||||
Ok(Self(dir))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` as a `file://` URL, which `-env:UserInstallation` takes.
|
||||
pub(super) fn file_url(path: &Path) -> String {
|
||||
let path = path.to_string_lossy().replace('\\', "/");
|
||||
let mut url = String::from("file://");
|
||||
// A Windows path (`C:/…`) needs the third slash a Unix path already starts with.
|
||||
if !path.starts_with('/') {
|
||||
url.push('/');
|
||||
}
|
||||
for b in path.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) {
|
||||
url.push(char::from(b));
|
||||
} else {
|
||||
url.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
pub(super) enum Run {
|
||||
Exited(ExitStatus),
|
||||
TimedOut,
|
||||
/// The watched folder grew past its limit while the command ran.
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// The bytes in the files directly inside `dir` (LibreOffice writes its one output there).
|
||||
/// Anything unreadable counts as nothing, and the sum saturates. Each size is read from the
|
||||
/// file itself: on Windows a directory listing keeps the old size while a process still
|
||||
/// has the file open for writing.
|
||||
pub(super) fn folder_size(dir: &Path) -> u64 {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| std::fs::symlink_metadata(entry.path()).ok())
|
||||
.filter(|meta| meta.is_file())
|
||||
.fold(0u64, |sum, meta| sum.saturating_add(meta.len()))
|
||||
}
|
||||
|
||||
/// Run `command` to completion, killing it and every process it started once `timeout`
|
||||
/// passes, or once the files in `watch`'s folder add up to more than its limit (so a
|
||||
/// document that converts to an enormous PDF can't fill the disk before it is checked).
|
||||
/// `soffice` hands the conversion to its own child, `soffice.bin`, which would
|
||||
/// otherwise outlive the kill, holding the profile folder and its memory.
|
||||
pub(super) fn run_limited(mut command: Command, timeout: Duration, watch: Option<(&Path, u64)>) -> std::io::Result<Run> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
// Its own process group, so the whole group can be stopped together.
|
||||
command.process_group(0);
|
||||
}
|
||||
let mut child = command.spawn()?;
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Ok(Run::Exited(status)),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
kill_tree(&mut child);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if watch.is_some_and(|(dir, limit)| folder_size(dir) > limit) {
|
||||
kill_tree(&mut child);
|
||||
return Ok(Run::TooLarge);
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
kill_tree(&mut child);
|
||||
return Ok(Run::TimedOut);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill `child` and every process it started, then reap it.
|
||||
fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::process::{Pid, Signal, kill_process_group};
|
||||
// The group `process_group(0)` made has the child's id.
|
||||
if let Some(group) = i32::try_from(child.id()).ok().and_then(Pid::from_raw) {
|
||||
let _ = kill_process_group(group, Signal::KILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// `/T` walks the tree from the child's id, so it runs while the child is still alive.
|
||||
// Windows' own copy, not whatever `taskkill` the PATH finds first.
|
||||
let taskkill = std::env::var_os("SystemRoot")
|
||||
.map_or_else(|| PathBuf::from("taskkill.exe"), |root| PathBuf::from(root).join("System32").join("taskkill.exe"));
|
||||
let _ = Command::new(taskkill)
|
||||
.args(["/T", "/F", "/PID", &child.id().to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
// CREATE_NO_WINDOW, as for LibreOffice itself.
|
||||
.creation_flags(0x0800_0000)
|
||||
.status();
|
||||
}
|
||||
// The child itself, if the tree kill couldn't reach it.
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// Convert with the LibreOffice at `program`, giving up after `timeout`.
|
||||
pub(super) fn convert_with(program: &Path, name: &str, bytes: &[u8], timeout: Duration) -> Result<Vec<u8>, CreateError> {
|
||||
convert_capped(program, name, bytes, timeout, MAX_PDF)
|
||||
}
|
||||
|
||||
/// [`convert_with`] with the converted PDF's size limit given: the output folder is watched
|
||||
/// while LibreOffice runs, and the finished file is checked again.
|
||||
pub(super) fn convert_capped(program: &Path, name: &str, bytes: &[u8], timeout: Duration, max_pdf: u64) -> Result<Vec<u8>, CreateError> {
|
||||
let too_large = || format!("the converted PDF is larger than {} MB", max_pdf / (1024 * 1024));
|
||||
let failed = |why: String| CreateError::Office(format!("{name}: {why}"));
|
||||
let ext = office_extension(name).ok_or_else(|| failed("this isn't a Word, Excel or PowerPoint file".into()))?;
|
||||
let work = WorkDir::new().map_err(|e| failed(format!("couldn't make a temporary folder: {e}")))?;
|
||||
// A fixed input name: the document's own name never reaches LibreOffice's command line.
|
||||
let input = work.0.join(format!("document.{ext}"));
|
||||
let profile = work.0.join("profile");
|
||||
let out = work.0.join("out");
|
||||
let prepared = std::fs::write(&input, bytes)
|
||||
.and_then(|()| std::fs::create_dir_all(profile.join("user")))
|
||||
.and_then(|()| std::fs::write(profile.join("user").join("registrymodifications.xcu"), PROFILE_SETTINGS))
|
||||
.and_then(|()| std::fs::create_dir(&out));
|
||||
prepared.map_err(|e| failed(format!("couldn't prepare the conversion: {e}")))?;
|
||||
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.arg(format!("-env:UserInstallation={}", file_url(&profile)))
|
||||
.args(["--headless", "--invisible", "--nologo", "--norestore", "--nodefault", "--nolockcheck", "--convert-to", "pdf", "--outdir"])
|
||||
.arg(&out)
|
||||
.arg(&input)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW: no console window flashes up during the conversion.
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
let status = match run_limited(command, timeout, Some((&out, max_pdf))) {
|
||||
Ok(Run::Exited(status)) => status,
|
||||
Ok(Run::TooLarge) => return Err(failed(format!("{}; LibreOffice was stopped", too_large()))),
|
||||
Ok(Run::TimedOut) => return Err(failed(format!("LibreOffice took longer than {} seconds and was stopped", timeout.as_secs()))),
|
||||
Err(e) => return Err(failed(format!("couldn't start LibreOffice ({}): {e}", program.display()))),
|
||||
};
|
||||
let pdf = out.join("document.pdf");
|
||||
let size = match std::fs::metadata(&pdf) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(_) if !status.success() => return Err(failed(format!("LibreOffice couldn't convert it ({status})"))),
|
||||
Err(_) => return Err(failed("LibreOffice didn't produce a PDF; the file may be damaged or password-protected".into())),
|
||||
};
|
||||
if size > max_pdf {
|
||||
return Err(failed(too_large()));
|
||||
}
|
||||
let bytes = std::fs::read(&pdf).map_err(|e| failed(format!("couldn't read the converted PDF: {e}")))?;
|
||||
if !bytes.starts_with(b"%PDF-") {
|
||||
return Err(failed("LibreOffice's output isn't a PDF".into()));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DOCX_HEAD: &[u8] = b"PK\x03\x04\x14\x00\x06\x00";
|
||||
|
||||
#[test]
|
||||
fn office_files_are_recognised_by_extension_and_signature() {
|
||||
assert!(is_office("Report.docx", DOCX_HEAD));
|
||||
assert!(is_office("SHEET.XLSX", DOCX_HEAD));
|
||||
assert!(is_office("deck.odp", DOCX_HEAD));
|
||||
assert!(is_office("old.doc", OLE));
|
||||
assert!(is_office("old.ppt", &[OLE, b"rest"].concat()));
|
||||
assert!(is_office("letter.rtf", b"{\\rtf1\\ansi"));
|
||||
// Word saves RTF under .doc too.
|
||||
assert!(is_office("letter.doc", b"{\\rtf1\\ansi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_are_not_office_documents() {
|
||||
// The right signature with another extension, or an Office name with other bytes.
|
||||
assert!(!is_office("archive.zip", DOCX_HEAD));
|
||||
assert!(!is_office("Report.docx", b"%PDF-1.7"));
|
||||
assert!(!is_office("Report.docx", b"plain text"));
|
||||
assert!(!is_office("Report.docx", b""));
|
||||
assert!(!is_office("docx", DOCX_HEAD));
|
||||
assert!(!is_office("", DOCX_HEAD));
|
||||
// A multi-byte name never splits a character.
|
||||
assert!(!is_office("résumé.dócx", DOCX_HEAD));
|
||||
assert!(is_office("résumé.docx", DOCX_HEAD));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use super::super::native::*;
|
||||
use super::DOCX_HEAD;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn file_urls_escape_spaces_and_keep_drive_letters() {
|
||||
assert_eq!(file_url(Path::new("/tmp/a b/profile")), "file:///tmp/a%20b/profile");
|
||||
assert_eq!(file_url(Path::new(r"C:\Users\Jo Ann\profile")), "file:///C:/Users/Jo%20Ann/profile");
|
||||
assert_eq!(file_url(Path::new("/tmp/é")), "file:///tmp/%C3%A9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_override_comes_first_then_the_path_then_the_defaults() {
|
||||
let path = std::env::join_paths([Path::new("first"), Path::new("second")]).unwrap();
|
||||
let found = candidates(Some(OsString::from("custom/soffice")), Some(path), vec![PathBuf::from("default/soffice")]);
|
||||
assert_eq!(found.first(), Some(&PathBuf::from("custom/soffice")));
|
||||
assert!(found.get(1).is_some_and(|p| p.starts_with("first")));
|
||||
assert_eq!(found.last(), Some(&PathBuf::from("default/soffice")));
|
||||
// An empty override is no override.
|
||||
assert_eq!(candidates(Some(OsString::new()), None, Vec::new()), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_locations_read_only_the_given_environment() {
|
||||
let found = default_locations(&|_| None);
|
||||
assert!(found.iter().all(|p| p.is_absolute()), "{found:?}");
|
||||
}
|
||||
|
||||
/// Each setting the fresh profile makes, as (path, property, value).
|
||||
fn profile_settings() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
let between = |s: &'static str, start: &str, end: &str| s.split_once(start).and_then(|(_, rest)| rest.split_once(end)).map(|(v, _)| v);
|
||||
PROFILE_SETTINGS
|
||||
.split("<item ")
|
||||
.skip(1)
|
||||
.filter_map(|item| {
|
||||
Some((between(item, "oor:path=\"", "\"")?, between(item, "oor:name=\"", "\"")?, between(item, "<value>", "</value>")?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_turns_off_macros_and_never_updates_links() {
|
||||
let settings = profile_settings();
|
||||
let scripting = "/org.openoffice.Office.Common/Security/Scripting";
|
||||
assert!(settings.contains(&(scripting, "DisableMacrosExecution", "true")), "{settings:?}");
|
||||
assert!(settings.contains(&(scripting, "MacroSecurityLevel", "3")), "{settings:?}");
|
||||
assert!(settings.contains(&(scripting, "BlockUntrustedRefererLinks", "true")), "{settings:?}");
|
||||
// "Never" is 2 for Writer but 1 for Calc (Writer.xcs and Calc.xcs); Writer's 1 is
|
||||
// its default, "On request".
|
||||
assert!(settings.contains(&("/org.openoffice.Office.Writer/Content/Update", "Link", "2")), "{settings:?}");
|
||||
assert!(settings.contains(&("/org.openoffice.Office.Calc/Content/Update", "Link", "1")), "{settings:?}");
|
||||
assert_eq!(settings.len(), 5, "{settings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_converter_is_an_error_not_a_crash() {
|
||||
let missing = std::env::temp_dir().join("pdfcraft-no-such-soffice").join("soffice");
|
||||
let error = convert_with(&missing, "Report.docx", DOCX_HEAD, Duration::from_secs(5)).unwrap_err();
|
||||
assert!(error.to_string().contains("couldn't start LibreOffice"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_converter_that_makes_no_pdf_is_reported() {
|
||||
// This test binary stands in for LibreOffice: it rejects the arguments and exits
|
||||
// without writing anything.
|
||||
let program = std::env::current_exe().unwrap();
|
||||
let error = convert_with(&program, "Report.docx", DOCX_HEAD, Duration::from_secs(60)).unwrap_err();
|
||||
assert!(error.to_string().contains("LibreOffice"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_converter_that_hangs_is_stopped() {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
command.args(["--ignored", "--exact", "office::tests::native::sleeps_for_a_minute", "--nocapture"]);
|
||||
command.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
|
||||
let started = std::time::Instant::now();
|
||||
assert!(matches!(run_limited(command, Duration::from_millis(500), None), Ok(Run::TimedOut)));
|
||||
assert!(started.elapsed() < Duration::from_secs(30));
|
||||
}
|
||||
|
||||
/// The stand-in for a LibreOffice that never finishes (run only by the test above).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn sleeps_for_a_minute() {
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
|
||||
/// Where the stand-ins below write their heartbeat.
|
||||
const HEARTBEAT: &str = "PDFCRAFT_TEST_OFFICE_HEARTBEAT";
|
||||
|
||||
/// This test binary run as one of its own ignored tests, with the heartbeat file set.
|
||||
fn stand_in(test: &str, heartbeat: &Path) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
command.args(["--ignored", "--exact", test, "--nocapture"]).env(HEARTBEAT, heartbeat);
|
||||
command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
|
||||
command
|
||||
}
|
||||
|
||||
/// `soffice` starts `soffice.bin` as its own child: a timeout must stop both, or the
|
||||
/// child keeps running, holding the profile folder.
|
||||
#[test]
|
||||
fn a_timeout_stops_the_processes_the_converter_started() {
|
||||
let work = WorkDir::new().unwrap();
|
||||
let heartbeat = work.0.join("heartbeat");
|
||||
let parent = stand_in("office::tests::native::starts_a_child_that_keeps_beating", &heartbeat);
|
||||
assert!(matches!(run_limited(parent, Duration::from_secs(5), None), Ok(Run::TimedOut)));
|
||||
let beats = || std::fs::metadata(&heartbeat).map_or(0, |m| m.len());
|
||||
// A killed process may take a moment to go.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let after_kill = beats();
|
||||
assert!(after_kill > 0, "the grandchild started and was beating before the timeout");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
assert_eq!(beats(), after_kill, "the grandchild was stopped with its parent");
|
||||
}
|
||||
|
||||
/// The stand-in for `soffice`: starts [`beats_until_stopped`] and waits (run only by the
|
||||
/// test above).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn starts_a_child_that_keeps_beating() {
|
||||
let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return };
|
||||
let mut child = stand_in("office::tests::native::beats_until_stopped", Path::new(&heartbeat)).spawn().unwrap();
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// The stand-in for `soffice.bin`: appends to the heartbeat file every 50 ms for a minute.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn beats_until_stopped() {
|
||||
use std::io::Write;
|
||||
let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return };
|
||||
let mut file = std::fs::OpenOptions::new().create(true).append(true).open(heartbeat).unwrap();
|
||||
for _ in 0..1200 {
|
||||
file.write_all(b".").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the stand-in below writes.
|
||||
const FILL_DIR: &str = "PDFCRAFT_TEST_OFFICE_FILL_DIR";
|
||||
|
||||
/// A converter that writes without end is stopped as soon as its output passes the
|
||||
/// limit, not after it has run out its time.
|
||||
#[test]
|
||||
fn a_converter_whose_output_grows_past_the_limit_is_stopped() {
|
||||
let work = WorkDir::new().unwrap();
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
command.args(["--ignored", "--exact", "office::tests::native::writes_without_end", "--nocapture"]).env(FILL_DIR, &work.0);
|
||||
command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
|
||||
let limit = 256 * 1024;
|
||||
let started = std::time::Instant::now();
|
||||
let run = run_limited(command, Duration::from_secs(60), Some((&work.0, limit)));
|
||||
assert!(matches!(run, Ok(Run::TooLarge)), "stopped for size, not for time");
|
||||
assert!(started.elapsed() < Duration::from_secs(30), "stopped long before the timeout");
|
||||
// A killed process may take a moment to go; after that nothing is written.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let after_kill = folder_size(&work.0);
|
||||
assert!(after_kill > limit, "the stand-in had written past the limit");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
assert_eq!(folder_size(&work.0), after_kill, "the converter was stopped");
|
||||
}
|
||||
|
||||
/// The stand-in for a LibreOffice that fills the disk: 64 KiB every 10 ms for a minute
|
||||
/// (run only by the test above).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn writes_without_end() {
|
||||
use std::io::Write;
|
||||
let Some(dir) = std::env::var_os(FILL_DIR) else { return };
|
||||
let mut file = std::fs::File::create(Path::new(&dir).join("document.pdf")).unwrap();
|
||||
let chunk = vec![0u8; 64 * 1024];
|
||||
for _ in 0..6000 {
|
||||
file.write_all(&chunk).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_read_counts_as_empty() {
|
||||
assert_eq!(folder_size(&std::env::temp_dir().join("pdfcraft-no-such-folder")), 0);
|
||||
}
|
||||
|
||||
/// The temporary folder holds a confidential document and its PDF: on Unix only the owner
|
||||
/// may enter it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_temporary_folder_is_private_to_its_owner() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let work = WorkDir::new().unwrap();
|
||||
let mode = std::fs::metadata(&work.0).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700, "{mode:o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_folders_are_removed_with_their_contents() {
|
||||
let work = WorkDir::new().unwrap();
|
||||
let other = WorkDir::new().unwrap();
|
||||
assert_ne!(work.0, other.0);
|
||||
let dir = work.0.clone();
|
||||
std::fs::create_dir_all(dir.join("profile").join("user")).unwrap();
|
||||
std::fs::write(dir.join("document.docx"), DOCX_HEAD).unwrap();
|
||||
drop(work);
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
/// A real conversion, when LibreOffice is installed (skipped otherwise).
|
||||
#[test]
|
||||
fn libreoffice_converts_a_document_when_installed() {
|
||||
let Some(program) = super::super::libreoffice() else {
|
||||
eprintln!("skipped: LibreOffice isn't installed");
|
||||
return;
|
||||
};
|
||||
let rtf = b"{\\rtf1\\ansi{\\fonttbl\\f0\\fswiss Helvetica;}\\f0\\pard Hello from PdfCraft.\\par}";
|
||||
let pdf = convert_with(&program, "hello.rtf", rtf, super::super::TIMEOUT).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,7 +559,7 @@ fn source_kind_tells_pdfs_images_and_text_apart() {
|
||||
// Text that merely starts like a BMP stays text; a truncated BMP header is not an image.
|
||||
assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text));
|
||||
assert_eq!(source_kind("b.bmp", b"BM"), None);
|
||||
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None);
|
||||
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), Some(SourceKind::Office), "Office documents convert through LibreOffice");
|
||||
assert_eq!(source_kind("", b""), None);
|
||||
// Only the GIF versions Create decodes count as images, so detection never promises a
|
||||
// conversion that then fails.
|
||||
@@ -2952,3 +2952,19 @@ fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() {
|
||||
// The helper puts a stray byte before the chunks, which the decoder skips: it stays.
|
||||
assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convertible_is_the_image_and_text_extensions() {
|
||||
let both: Vec<&str> = IMAGE_EXTENSIONS.iter().chain(TEXT_EXTENSIONS.iter()).copied().collect();
|
||||
assert_eq!(both, CONVERTIBLE.to_vec());
|
||||
assert!(OFFICE_EXTENSIONS.iter().all(|e| !CONVERTIBLE.contains(e) && *e != "pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_documents_are_their_own_kind() {
|
||||
assert_eq!(source_kind("Report.docx", b"PK\x03\x04\x14\x00"), Some(SourceKind::Office));
|
||||
assert_eq!(source_kind("Budget.xls", &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]), Some(SourceKind::Office));
|
||||
// A PDF saved with a Word name is still a PDF; a ZIP that isn't named like Office is nothing.
|
||||
assert_eq!(source_kind("Report.docx", b"%PDF-1.7\n"), Some(SourceKind::Pdf));
|
||||
assert_eq!(source_kind("photos.zip", b"PK\x03\x04\x14\x00"), None);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ md-5 = "0.11"
|
||||
sha2 = "0.11"
|
||||
stringprep = "0.1"
|
||||
thiserror.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
+65
-53
@@ -19,6 +19,7 @@ mod rc4;
|
||||
|
||||
use md5::{Digest as _, Md5};
|
||||
use sha2::{Sha256, Sha384, Sha512};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt};
|
||||
pub use rc4::rc4;
|
||||
@@ -126,6 +127,13 @@ impl Permissions {
|
||||
pub fn assemble(&self) -> bool {
|
||||
if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) }
|
||||
}
|
||||
/// Take pages out into another document (Extract, Split, a source of Combine or Insert
|
||||
/// Pages): [`Self::assemble`] and copying (bit 5), since the pages carry their text and
|
||||
/// graphics into a new file without the document's protection (#5081). "Any except
|
||||
/// extracting pages" sets bit 4 but not bit 11, so at revision 3 and later it grants neither.
|
||||
pub fn extract_pages(&self) -> bool {
|
||||
self.assemble() && self.copy()
|
||||
}
|
||||
/// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those
|
||||
/// revisions only: at revision 2 the print flag (bit 3) is all there is.
|
||||
pub fn print_high_quality(&self) -> bool {
|
||||
@@ -147,9 +155,9 @@ pub enum StreamKind<'a> {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SecurityHandler {
|
||||
dict: EncryptDict,
|
||||
key: Vec<u8>,
|
||||
key: Zeroizing<Vec<u8>>,
|
||||
auth: Auth,
|
||||
recovered_user: Option<Vec<u8>>,
|
||||
recovered_user: Option<Zeroizing<Vec<u8>>>,
|
||||
methods: Methods,
|
||||
}
|
||||
|
||||
@@ -190,7 +198,7 @@ impl Methods {
|
||||
}
|
||||
|
||||
impl SecurityHandler {
|
||||
fn new(dict: EncryptDict, key: Vec<u8>, auth: Auth, recovered_user: Option<Vec<u8>>) -> Self {
|
||||
fn new(dict: EncryptDict, key: Zeroizing<Vec<u8>>, auth: Auth, recovered_user: Option<Zeroizing<Vec<u8>>>) -> Self {
|
||||
let methods = Methods::new(&dict);
|
||||
Self { dict, key, auth, recovered_user, methods }
|
||||
}
|
||||
@@ -248,7 +256,7 @@ impl SecurityHandler {
|
||||
if methods.string != Method::Identity || methods.stream != Method::Identity {
|
||||
return None;
|
||||
}
|
||||
Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods })
|
||||
Some(Self { dict, key: Zeroizing::new(Vec::new()), auth: Auth::User, recovered_user: None, methods })
|
||||
}
|
||||
|
||||
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
|
||||
@@ -289,7 +297,7 @@ impl SecurityHandler {
|
||||
/// For R2–R4 documents opened with the owner password: the user password, which owner
|
||||
/// authentication recovers (Algorithm 7). Lets components that only accept user passwords
|
||||
/// open the document too.
|
||||
pub fn recovered_user_password(&self) -> Option<Vec<u8>> {
|
||||
pub fn recovered_user_password(&self) -> Option<Zeroizing<Vec<u8>>> {
|
||||
self.recovered_user.clone()
|
||||
}
|
||||
|
||||
@@ -306,7 +314,7 @@ impl SecurityHandler {
|
||||
if !matches!(self.dict.r, 5 | 6) || !self.has_key() {
|
||||
return None;
|
||||
}
|
||||
let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
|
||||
let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false));
|
||||
let p = i32::from_le_bytes(*plain.first_chunk::<4>()?);
|
||||
(plain.get(9..12) == Some(&b"adb"[..])).then_some(p)
|
||||
}
|
||||
@@ -348,7 +356,7 @@ impl SecurityHandler {
|
||||
/// the same way, so a page it shows is one this crate can read and save. AES-128 keys are
|
||||
/// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only
|
||||
/// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already.
|
||||
fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec<u8> {
|
||||
fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing<Vec<u8>> {
|
||||
if method == Method::Aes256 {
|
||||
return self.key.clone();
|
||||
}
|
||||
@@ -363,8 +371,9 @@ impl SecurityHandler {
|
||||
if method == Method::Aes128 {
|
||||
h.update(b"sAlT");
|
||||
}
|
||||
let digest = h.finalize();
|
||||
digest[..(key.len() + 5).min(16)].to_vec()
|
||||
let mut digest = Zeroizing::new(h.finalize().to_vec());
|
||||
digest.truncate((key.len() + 5).min(16));
|
||||
digest
|
||||
}
|
||||
|
||||
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
|
||||
@@ -456,31 +465,31 @@ pub fn pdfdoc_byte(c: char) -> Option<u8> {
|
||||
|
||||
/// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes.
|
||||
/// Characters PDFDocEncoding lacks are dropped.
|
||||
fn legacy_password(pw: &str) -> Vec<u8> {
|
||||
pw.chars().filter_map(pdfdoc_byte).take(32).collect()
|
||||
fn legacy_password(pw: &str) -> Zeroizing<Vec<u8>> {
|
||||
Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect())
|
||||
}
|
||||
|
||||
/// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification
|
||||
/// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives
|
||||
/// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords.
|
||||
fn legacy_candidates(pw: &str) -> Vec<Vec<u8>> {
|
||||
fn legacy_candidates(pw: &str) -> Vec<Zeroizing<Vec<u8>>> {
|
||||
let mut out = vec![legacy_password(pw)];
|
||||
if pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
|
||||
out.push(pw.bytes().take(32).collect());
|
||||
out.push(Zeroizing::new(pw.bytes().take(32).collect()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes.
|
||||
fn sasl_password(pw: &str) -> Vec<u8> {
|
||||
let prepped = stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string());
|
||||
let mut bytes = prepped.into_bytes();
|
||||
fn sasl_password(pw: &str) -> Zeroizing<Vec<u8>> {
|
||||
let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string()));
|
||||
let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec());
|
||||
bytes.truncate(127);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn pad(pw: &[u8]) -> [u8; 32] {
|
||||
let mut out = PADDING;
|
||||
fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> {
|
||||
let mut out = Zeroizing::new(PADDING);
|
||||
let n = pw.len().min(32);
|
||||
out[..n].copy_from_slice(&pw[..n]);
|
||||
out[n..].copy_from_slice(&PADDING[..32 - n]);
|
||||
@@ -494,20 +503,20 @@ fn key_len(d: &EncryptDict) -> usize {
|
||||
// ── Revisions 2–4 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Algorithm 2: file key from a (user) password.
|
||||
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Vec<u8> {
|
||||
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing<Vec<u8>> {
|
||||
let n = key_len(d);
|
||||
let mut h = Md5::new();
|
||||
h.update(pad(pw));
|
||||
h.update(*pad(pw));
|
||||
h.update(&d.o[..d.o.len().min(32)]);
|
||||
h.update(d.p.to_le_bytes());
|
||||
h.update(id0);
|
||||
if d.r >= 4 && !d.encrypt_metadata {
|
||||
h.update([0xFF; 4]);
|
||||
}
|
||||
let mut digest = h.finalize().to_vec();
|
||||
let mut digest = Zeroizing::new(h.finalize().to_vec());
|
||||
if d.r >= 3 {
|
||||
for _ in 0..50 {
|
||||
digest = Md5::digest(&digest[..n]).to_vec();
|
||||
digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec());
|
||||
}
|
||||
}
|
||||
digest.truncate(n);
|
||||
@@ -524,7 +533,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
h.update(id0);
|
||||
let mut x = rc4(key, &h.finalize());
|
||||
for i in 1..=19u8 {
|
||||
let k: Vec<u8> = key.iter().map(|b| b ^ i).collect();
|
||||
let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::<Vec<u8>>());
|
||||
x = rc4(&k, &x);
|
||||
}
|
||||
x.extend_from_slice(&[0u8; 16]);
|
||||
@@ -536,7 +545,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
/// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the
|
||||
/// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation
|
||||
/// fails we retry the other way.
|
||||
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Vec<u8>> {
|
||||
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
|
||||
let check = |d: &EncryptDict| {
|
||||
let key = file_key_r4(d, id0, pw);
|
||||
let u = u_value_r4(d, id0, &key);
|
||||
@@ -547,54 +556,57 @@ fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The RC4 key derived from an owner password (Algorithm 3, steps a–d).
|
||||
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Vec<u8> {
|
||||
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing<Vec<u8>> {
|
||||
let n = key_len(d);
|
||||
let mut digest = Md5::digest(pad(owner_pw)).to_vec();
|
||||
let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec());
|
||||
if d.r >= 3 {
|
||||
for _ in 0..50 {
|
||||
digest = Md5::digest(&digest).to_vec();
|
||||
digest = Zeroizing::new(Md5::digest(&*digest).to_vec());
|
||||
}
|
||||
}
|
||||
digest.truncate(n);
|
||||
digest
|
||||
}
|
||||
|
||||
type SecretBytes = Zeroizing<Vec<u8>>;
|
||||
|
||||
/// Algorithm 7: authenticate an owner password (recover the user password from `/O`).
|
||||
/// Returns the file key and the recovered user password (padding removed).
|
||||
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> {
|
||||
let rk = owner_rc4_key(d, pw);
|
||||
let mut user = d.o[..d.o.len().min(32)].to_vec();
|
||||
let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec());
|
||||
if d.r == 2 {
|
||||
user = rc4(&rk, &user);
|
||||
user = Zeroizing::new(rc4(&rk, &user));
|
||||
} else {
|
||||
for i in (0..=19u8).rev() {
|
||||
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
|
||||
user = rc4(&k, &user);
|
||||
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
|
||||
user = Zeroizing::new(rc4(&k, &user));
|
||||
}
|
||||
}
|
||||
let key = user_key_r4(d, id0, &user)?;
|
||||
// The recovered value is the padded password: strip the padding suffix.
|
||||
let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32));
|
||||
Some((key, user[..len].to_vec()))
|
||||
user.truncate(len);
|
||||
Some((key, user))
|
||||
}
|
||||
|
||||
// ── Revisions 5–6 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B).
|
||||
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
|
||||
let mut k = {
|
||||
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing<Vec<u8>> {
|
||||
let mut k = Zeroizing::new({
|
||||
let mut h = Sha256::new();
|
||||
h.update(pw);
|
||||
h.update(salt);
|
||||
h.update(udata);
|
||||
h.finalize().to_vec()
|
||||
};
|
||||
});
|
||||
if r == 5 {
|
||||
return k;
|
||||
}
|
||||
let mut round = 0u32;
|
||||
loop {
|
||||
let mut k1 = Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()));
|
||||
let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len())));
|
||||
for _ in 0..64 {
|
||||
k1.extend_from_slice(pw);
|
||||
k1.extend_from_slice(&k);
|
||||
@@ -603,13 +615,13 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
|
||||
// `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16.
|
||||
let Some((key, rest)) = k.split_first_chunk::<16>() else { break };
|
||||
let Some(iv) = rest.first_chunk::<16>() else { break };
|
||||
let e = aes_cbc_encrypt(key, iv, &k1, false);
|
||||
let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false));
|
||||
let m = e.iter().take(16).map(|b| u32::from(*b)).sum::<u32>() % 3;
|
||||
k = match m {
|
||||
0 => Sha256::digest(&e).to_vec(),
|
||||
1 => Sha384::digest(&e).to_vec(),
|
||||
_ => Sha512::digest(&e).to_vec(),
|
||||
};
|
||||
k = Zeroizing::new(match m {
|
||||
0 => Sha256::digest(&*e).to_vec(),
|
||||
1 => Sha384::digest(&*e).to_vec(),
|
||||
_ => Sha512::digest(&*e).to_vec(),
|
||||
});
|
||||
round += 1;
|
||||
if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) {
|
||||
break;
|
||||
@@ -620,19 +632,19 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key.
|
||||
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Vec<u8>>, CryptError> {
|
||||
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Zeroizing<Vec<u8>>>, CryptError> {
|
||||
if d.u.len() < 48 || d.o.len() < 48 {
|
||||
return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into()));
|
||||
}
|
||||
let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) };
|
||||
if hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
|
||||
if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
|
||||
return Ok(None);
|
||||
}
|
||||
if encrypted.len() < 32 {
|
||||
return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into()));
|
||||
}
|
||||
let k = hash_r6(d.r, pw, &hashed[40..48], udata);
|
||||
Ok(Some(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))
|
||||
Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false))))
|
||||
}
|
||||
|
||||
// ── Creating encryption ────────────────────────────────────────────────────────────────────────
|
||||
@@ -654,7 +666,7 @@ pub enum Algorithm {
|
||||
/// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed,
|
||||
/// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8,
|
||||
/// at most 127 bytes.
|
||||
fn prepared_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
|
||||
fn prepared_password(algorithm: Algorithm, password: &str) -> Zeroizing<Vec<u8>> {
|
||||
match algorithm {
|
||||
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password),
|
||||
Algorithm::Aes256 => sasl_password(password),
|
||||
@@ -672,7 +684,7 @@ pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
|
||||
let prepared = prepared_password(algorithm, password);
|
||||
match algorithm {
|
||||
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(),
|
||||
Algorithm::Aes256 => prepared,
|
||||
Algorithm::Aes256 => prepared.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,10 +749,10 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
|
||||
let owner = if params.owner_password.is_empty() { user.clone() } else { owner };
|
||||
// Algorithm 3: /O
|
||||
let rk = owner_rc4_key(&d, &owner);
|
||||
let mut o = rc4(&rk, &pad(&user));
|
||||
let mut o = rc4(&rk, &*pad(&user));
|
||||
if d.r >= 3 {
|
||||
for i in 1..=19u8 {
|
||||
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
|
||||
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
|
||||
o = rc4(&k, &o);
|
||||
}
|
||||
}
|
||||
@@ -754,15 +766,15 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
|
||||
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes256)];
|
||||
d.stm_f = b"StdCF".to_vec();
|
||||
d.str_f = b"StdCF".to_vec();
|
||||
let key = expand(¶ms.seed, b"file key", 32);
|
||||
let key = Zeroizing::new(expand(¶ms.seed, b"file key", 32));
|
||||
let salts = expand(¶ms.seed, b"salts", 32);
|
||||
// Algorithm 8: /U, /UE
|
||||
let mut u = hash_r6(6, &user, &salts[0..8], &[]);
|
||||
let mut u = hash_r6(6, &user, &salts[0..8], &[]).to_vec();
|
||||
u.extend_from_slice(&salts[0..16]);
|
||||
let uk = hash_r6(6, &user, &salts[8..16], &[]);
|
||||
d.ue = aes_cbc_encrypt(&uk, &[0; 16], &key, false);
|
||||
// Algorithm 9: /O, /OE
|
||||
let mut o = hash_r6(6, &owner, &salts[16..24], &u);
|
||||
let mut o = hash_r6(6, &owner, &salts[16..24], &u).to_vec();
|
||||
o.extend_from_slice(&salts[16..32]);
|
||||
let ok = hash_r6(6, &owner, &salts[24..32], &u);
|
||||
d.oe = aes_cbc_encrypt(&ok, &[0; 16], &key, false);
|
||||
|
||||
@@ -248,6 +248,23 @@ fn permission_bits_follow_table_22() {
|
||||
assert!(p.modify(), "owners ignore /P");
|
||||
}
|
||||
|
||||
/// Page extraction (#5081) is assembly as the revision reads it, and copying (bit 5).
|
||||
#[test]
|
||||
fn page_extraction_needs_assembly_and_copying() {
|
||||
let bit = |n: u32| 1i32 << (n - 1);
|
||||
let user = |bits: i32, revision: i64| Permissions { bits, owner: false, revision };
|
||||
// "Any except extracting pages" (bits 4, 6, 9; copying off) at revision 3 and later.
|
||||
assert!(!user(bit(4) | bit(6) | bit(9), 4).extract_pages());
|
||||
// Pages may change in place, but copying is withheld.
|
||||
assert!(user(bit(11), 6).assemble() && !user(bit(11), 6).extract_pages());
|
||||
assert!(user(bit(11) | bit(5), 6).extract_pages());
|
||||
// Revision 2: bit 4 covers assembly, and its reserved bit 11 grants nothing.
|
||||
assert!(user(bit(4), 2).assemble() && !user(bit(4) | bit(11), 2).extract_pages(), "copying withheld");
|
||||
assert!(user(bit(4) | bit(5), 2).extract_pages());
|
||||
assert!(!user(bit(5) | bit(11), 2).extract_pages(), "no assembly without bit 4 at revision 2");
|
||||
assert!(Permissions { bits: 0, owner: true, revision: 2 }.extract_pages(), "owners ignore /P");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perms_limits_what_an_edited_p_grants() {
|
||||
let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only
|
||||
@@ -348,11 +365,13 @@ fn owner_authentication_recovers_the_user_password() {
|
||||
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
|
||||
let d = new(alg, "user pw", "owner pw", -1).dict().clone();
|
||||
let h = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap();
|
||||
assert_eq!(h.recovered_user_password().as_deref(), Some(&b"user pw"[..]), "{alg:?}");
|
||||
let _: &Zeroizing<Vec<u8>> = &h.key;
|
||||
let _: &Option<Zeroizing<Vec<u8>>> = &h.recovered_user;
|
||||
assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}");
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None);
|
||||
}
|
||||
let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone();
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..]));
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..]));
|
||||
}
|
||||
|
||||
/// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys
|
||||
@@ -393,7 +412,7 @@ fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() {
|
||||
assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f")));
|
||||
let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap();
|
||||
assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f")));
|
||||
assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0]));
|
||||
assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0])));
|
||||
// Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it.
|
||||
for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] {
|
||||
assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}");
|
||||
@@ -423,6 +442,6 @@ fn pdfdoc_encoding_round_trips() {
|
||||
for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] {
|
||||
assert_eq!(pdfdoc_byte(c), None, "{c}");
|
||||
}
|
||||
assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]);
|
||||
assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]);
|
||||
assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]);
|
||||
assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]);
|
||||
}
|
||||
@@ -119,6 +119,8 @@ pub fn scan_markers(doc: &Document) -> MarkerScan {
|
||||
Some(MarkKind::Watermark)
|
||||
} else if tag == Some(&b"Background"[..]) {
|
||||
Some(MarkKind::Background)
|
||||
} else if tag == Some(&b"PrinterMarks"[..]) {
|
||||
Some(MarkKind::PrinterMarks)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Colours as sRGB through the colour space in force, for Convert colours: `g`/`rg`/`k`, and
|
||||
//! `cs` + `sc`/`scn` through the space named. Device spaces, ICCBased by its component count (or
|
||||
//! its Alternate), CalRGB and CalGray as their device kin, Lab through CIE XYZ with the
|
||||
//! dictionary's white point, Indexed through its table, Separation and DeviceN through a Type 2
|
||||
//! (exponential) tint transform into the alternate space; a sampled or PostScript tint function,
|
||||
//! or a pattern, falls back to a plain darkness so nothing reads as the wrong hue.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
/// A colour space as far as a fill colour needs it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Space {
|
||||
Gray,
|
||||
Rgb,
|
||||
Cmyk,
|
||||
Lab {
|
||||
white: [f64; 3],
|
||||
range: [f64; 4],
|
||||
},
|
||||
Indexed {
|
||||
base: Box<Space>,
|
||||
hival: usize,
|
||||
table: Vec<u8>,
|
||||
},
|
||||
/// Separation and DeviceN: the number of inputs, the alternate space, and the tint
|
||||
/// transform when it is a Type 2 function (C0, C1, N).
|
||||
Tint {
|
||||
inputs: usize,
|
||||
alt: Box<Space>,
|
||||
exp: Option<(Vec<f64>, Vec<f64>, f64)>,
|
||||
},
|
||||
Pattern,
|
||||
}
|
||||
|
||||
impl Space {
|
||||
/// How many numbers set a colour in this space.
|
||||
fn components(&self) -> usize {
|
||||
match self {
|
||||
Space::Gray => 1,
|
||||
Space::Rgb | Space::Lab { .. } => 3,
|
||||
Space::Cmyk => 4,
|
||||
Space::Indexed { .. } => 1,
|
||||
Space::Tint { inputs, .. } => *inputs,
|
||||
Space::Pattern => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, o: &Object) -> Vec<f64> {
|
||||
doc.resolve(o).as_array().map(|a| a.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The colour space an operand of `cs` names: a device space, or a name in the resources'
|
||||
/// `/ColorSpace` dictionary, or (inside arrays) a family.
|
||||
pub(crate) fn space_of(doc: &Document, operand: &Object, cs_res: &Dict, depth: u8) -> Space {
|
||||
if depth > 4 {
|
||||
return Space::Gray;
|
||||
}
|
||||
let resolved = doc.resolve(operand);
|
||||
match &*resolved {
|
||||
Object::Name(n) => match n.as_slice() {
|
||||
b"DeviceGray" | b"G" | b"CalGray" => Space::Gray,
|
||||
b"DeviceRGB" | b"RGB" | b"CalRGB" => Space::Rgb,
|
||||
b"DeviceCMYK" | b"CMYK" => Space::Cmyk,
|
||||
b"Pattern" => Space::Pattern,
|
||||
other => match cs_res.get(other) {
|
||||
Some(o) => space_of(doc, o, &Dict::default(), depth + 1),
|
||||
None => Space::Gray,
|
||||
},
|
||||
},
|
||||
Object::Array(a) => {
|
||||
let family = a.first().map(|f| doc.resolve(f)).and_then(|f| f.as_name().map(|n| n.to_vec())).unwrap_or_default();
|
||||
match family.as_slice() {
|
||||
b"ICCBased" => {
|
||||
let stream = a.get(1).map(|s| doc.resolve(s));
|
||||
let dict = stream.as_ref().and_then(|s| match &**s {
|
||||
Object::Stream(st) => Some(st.dict.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(alt) = dict.as_ref().and_then(|d| d.get(b"Alternate").cloned()) {
|
||||
return space_of(doc, &alt, cs_res, depth + 1);
|
||||
}
|
||||
match dict.and_then(|d| d.int(b"N")) {
|
||||
Some(1) => Space::Gray,
|
||||
Some(4) => Space::Cmyk,
|
||||
_ => Space::Rgb,
|
||||
}
|
||||
}
|
||||
b"CalRGB" => Space::Rgb,
|
||||
b"CalGray" => Space::Gray,
|
||||
b"Lab" => {
|
||||
let d = a.get(1).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default();
|
||||
let wp = d.get(b"WhitePoint").map(|w| nums(doc, w)).unwrap_or_default();
|
||||
let white = if wp.len() == 3 { [wp[0], wp[1], wp[2]] } else { [0.9505, 1.0, 1.089] };
|
||||
let r = d.get(b"Range").map(|w| nums(doc, w)).unwrap_or_default();
|
||||
let range = if r.len() == 4 { [r[0], r[1], r[2], r[3]] } else { [-100.0, 100.0, -100.0, 100.0] };
|
||||
Space::Lab { white, range }
|
||||
}
|
||||
b"Indexed" | b"I" => {
|
||||
let base = a.get(1).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Rgb);
|
||||
let hival = a.get(2).and_then(|h| doc.resolve(h).as_f64()).unwrap_or(0.0).clamp(0.0, 255.0) as usize;
|
||||
let table = match a.get(3).map(|t| doc.resolve(t)) {
|
||||
Some(t) => match &*t {
|
||||
Object::String(s) => s.bytes.clone(),
|
||||
Object::Stream(st) => st.decoded().unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
Space::Indexed { base: Box::new(base), hival, table }
|
||||
}
|
||||
b"Separation" | b"DeviceN" => {
|
||||
// The `/None` colorant paints nothing (ISO 32000-2, 8.6.6.4): given a colour
|
||||
// of its own it would appear, so it stays what it is, as a pattern does.
|
||||
let is_none = |o: &Object| doc.resolve(o).as_name() == Some(b"None");
|
||||
let none = a.get(1).is_some_and(|names| {
|
||||
is_none(names) || doc.resolve(names).as_array().is_some_and(|names| !names.is_empty() && names.iter().all(is_none))
|
||||
});
|
||||
if none {
|
||||
return Space::Pattern;
|
||||
}
|
||||
let inputs = if family == b"Separation" {
|
||||
1
|
||||
} else {
|
||||
a.get(1).map(|n| doc.resolve(n)).and_then(|n| n.as_array().map(Vec::len)).unwrap_or(1).max(1)
|
||||
};
|
||||
let alt = a.get(2).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Gray);
|
||||
let exp = a.get(3).map(|f| doc.resolve(f)).and_then(|f| {
|
||||
let d = match &*f {
|
||||
Object::Dict(d) => d.clone(),
|
||||
Object::Stream(s) => s.dict.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
if d.int(b"FunctionType") != Some(2) {
|
||||
return None;
|
||||
}
|
||||
let c0 = d.get(b"C0").map(|v| nums(doc, v)).unwrap_or_else(|| vec![0.0]);
|
||||
let c1 = d.get(b"C1").map(|v| nums(doc, v)).unwrap_or_else(|| vec![1.0]);
|
||||
let n = d.get(b"N").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(1.0);
|
||||
Some((c0, c1, n))
|
||||
});
|
||||
Space::Tint { inputs, alt: Box::new(alt), exp }
|
||||
}
|
||||
b"Pattern" => Space::Pattern,
|
||||
b"DeviceGray" | b"G" => Space::Gray,
|
||||
b"DeviceRGB" | b"RGB" => Space::Rgb,
|
||||
b"DeviceCMYK" | b"CMYK" => Space::Cmyk,
|
||||
_ => Space::Gray,
|
||||
}
|
||||
}
|
||||
_ => Space::Gray,
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp01(v: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// CMYK to sRGB, the usual multiplicative approximation (no ICC profile).
|
||||
pub(crate) fn cmyk(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] {
|
||||
let (c, m, y, k) = (clamp01(c), clamp01(m), clamp01(y), clamp01(k));
|
||||
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
|
||||
}
|
||||
|
||||
/// CIE L*a*b* (with the dictionary's white point) to sRGB, through XYZ and the D65 sRGB matrix.
|
||||
fn lab(white: [f64; 3], l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
let m = (l + 16.0) / 116.0;
|
||||
let (ll, mm, nn) = (m + a / 500.0, m, m - b / 200.0);
|
||||
let g = |x: f64| if x >= 6.0 / 29.0 { x * x * x } else { 108.0 / 841.0 * (x - 4.0 / 29.0) };
|
||||
let (xw, yw, zw) = (white[0].max(1e-6), white[1].max(1e-6), white[2].max(1e-6));
|
||||
let (x, y, z) = (xw * g(ll), yw * g(mm), zw * g(nn));
|
||||
// Scale the white point to D65 before the sRGB matrix (a plain von Kries over XYZ).
|
||||
let (x, y, z) = (x * 0.9505 / xw, y / yw, z * 1.089 / zw);
|
||||
let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z];
|
||||
lin.map(|c| {
|
||||
let c = clamp01(c);
|
||||
if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
|
||||
})
|
||||
}
|
||||
|
||||
/// The sRGB of `values` in `space` (missing values read as 0; a pattern reads as black).
|
||||
pub(crate) fn rgb(space: &Space, values: &[f64]) -> [f64; 3] {
|
||||
let v = |i: usize| values.get(i).copied().unwrap_or(0.0);
|
||||
match space {
|
||||
Space::Gray => {
|
||||
let g = clamp01(v(0));
|
||||
[g, g, g]
|
||||
}
|
||||
Space::Rgb => [clamp01(v(0)), clamp01(v(1)), clamp01(v(2))],
|
||||
Space::Cmyk => cmyk(v(0), v(1), v(2), v(3)),
|
||||
Space::Lab { white, range } => {
|
||||
let l = v(0).clamp(0.0, 100.0);
|
||||
let a = v(1).clamp(range[0].min(range[1]), range[1].max(range[0]));
|
||||
let b = v(2).clamp(range[2].min(range[3]), range[3].max(range[2]));
|
||||
lab(*white, l, a, b)
|
||||
}
|
||||
Space::Indexed { base, hival, table } => {
|
||||
let n = base.components().max(1);
|
||||
let i = (v(0).max(0.0).round() as usize).min(*hival);
|
||||
let vals: Vec<f64> = (0..n).map(|k| table.get(i * n + k).map(|b| *b as f64 / 255.0).unwrap_or(0.0)).collect();
|
||||
// Lab tables hold the L*, a*, b* range in bytes; the others are 0..1 already.
|
||||
match &**base {
|
||||
Space::Lab { range, .. } => {
|
||||
let scaled = [
|
||||
vals[0] * 100.0,
|
||||
range[0] + vals.get(1).copied().unwrap_or(0.0) * (range[1] - range[0]),
|
||||
range[2] + vals.get(2).copied().unwrap_or(0.0) * (range[3] - range[2]),
|
||||
];
|
||||
rgb(base, &scaled)
|
||||
}
|
||||
_ => rgb(base, &vals),
|
||||
}
|
||||
}
|
||||
Space::Tint { inputs, alt, exp } => match exp {
|
||||
Some((c0, c1, n)) => {
|
||||
let t = clamp01(v(0));
|
||||
let k = if *n == 1.0 { t } else { t.powf(*n) };
|
||||
let out: Vec<f64> = c0.iter().zip(c1.iter()).map(|(a, b)| a + k * (b - a)).collect();
|
||||
rgb(alt, &out)
|
||||
}
|
||||
// No usable transform: the darkness of the strongest tint, in the alternate space's ink.
|
||||
None => {
|
||||
let t = (0..*inputs).map(v).fold(0.0, f64::max);
|
||||
let g = 1.0 - clamp01(t);
|
||||
[g, g, g]
|
||||
}
|
||||
},
|
||||
Space::Pattern => [0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
//! Convert colours (Acrobat's Print production ▸ Convert Colors): every colour
|
||||
//! operator in the page content and the artwork it draws is rewritten in a target space -
|
||||
//! grayscale, RGB or CMYK - by the standard device formulas (no ICC profile is applied), black
|
||||
//! preserved on request; pictures are re-encoded through a helper the caller supplies.
|
||||
//!
|
||||
//! This is not a prepress separation. No ICC profile, rendering intent or output intent is
|
||||
//! used, so the numbers are not calibrated for any press, and the catalog's `/OutputIntents`
|
||||
//! stay as they were. A PDF/A or PDF/X file may no longer conform afterwards, and still says it
|
||||
//! does: nothing here changes what it declares (the `doc_convert_colours` tool lists what it
|
||||
//! declared; `standards_remove_id` takes that off).
|
||||
//!
|
||||
//! Spot colours are converted, not kept: Separation and DeviceN through a Type 2 tint transform
|
||||
//! into their alternate space, any other tint transform as a gray of the strongest tint's
|
||||
//! darkness (the hue is lost). `/All` converts like any other; `/None` paints nothing and stays.
|
||||
//! Patterns, shadings, inline images and transparency group spaces stay as they are.
|
||||
//!
|
||||
//! Soft masks are not touched: a picture keeps the one it has. The helper leaves a picture alone
|
||||
//! when it cannot re-encode it (indexed, CMYK, not 8-bit, an explicit mask, a matted soft mask,
|
||||
//! JPEG 2000 ...); those that are not in the target already are counted in
|
||||
//! [`Converted::images_left`].
|
||||
|
||||
use pdfcraft_content::{Op, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::colour::{Space, rgb, space_of};
|
||||
use crate::page_streams::PageStreams;
|
||||
use crate::{EditError, check, page_list};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ColourTarget {
|
||||
Gray,
|
||||
Rgb,
|
||||
Cmyk,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConvertColours {
|
||||
pub target: ColourTarget,
|
||||
/// Pure black stays 100% K (or gray 0) rather than a mix.
|
||||
pub preserve_black: bool,
|
||||
/// Re-encode pictures too (through the caller's helper).
|
||||
pub images: bool,
|
||||
/// The appearance streams of the pages' comments and form fields too.
|
||||
pub annotations: bool,
|
||||
}
|
||||
|
||||
impl Default for ConvertColours {
|
||||
fn default() -> Self {
|
||||
Self { target: ColourTarget::Gray, preserve_black: true, images: true, annotations: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Converted {
|
||||
/// Colour operators rewritten.
|
||||
pub colours: usize,
|
||||
/// Pictures re-encoded.
|
||||
pub images: usize,
|
||||
/// Pictures not in the target space that could not be re-encoded and are left as they were.
|
||||
pub images_left: usize,
|
||||
/// Pages on which something changed.
|
||||
pub pages: usize,
|
||||
}
|
||||
|
||||
/// Re-encode one image stream in the target space, if it can be: the caller's (the optimizer
|
||||
/// knows how to decode and encode pictures; the edit crate does not).
|
||||
pub type ImageRecolour<'a> = dyn FnMut(&Document, &Stream, ColourTarget) -> Option<Stream> + 'a;
|
||||
|
||||
fn luma(c: [f64; 3]) -> f64 {
|
||||
(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// sRGB → the target's components.
|
||||
pub fn components(target: ColourTarget, c: [f64; 3], preserve_black: bool) -> Vec<f64> {
|
||||
let black = c.iter().all(|v| *v < 0.02);
|
||||
match target {
|
||||
ColourTarget::Gray => vec![if black && preserve_black { 0.0 } else { luma(c) }],
|
||||
ColourTarget::Rgb => c.to_vec(),
|
||||
ColourTarget::Cmyk => {
|
||||
if black && preserve_black {
|
||||
return vec![0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
let k = 1.0 - c.iter().cloned().fold(0.0, f64::max);
|
||||
if k >= 0.999 {
|
||||
return vec![0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
let f = |v: f64| ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0);
|
||||
vec![f(c[0]), f(c[1]), f(c[2]), k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn op_for(target: ColourTarget, stroke: bool) -> &'static str {
|
||||
match (target, stroke) {
|
||||
(ColourTarget::Gray, false) => "g",
|
||||
(ColourTarget::Gray, true) => "G",
|
||||
(ColourTarget::Rgb, false) => "rg",
|
||||
(ColourTarget::Rgb, true) => "RG",
|
||||
(ColourTarget::Cmyk, false) => "k",
|
||||
(ColourTarget::Cmyk, true) => "K",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `cs` operand names DeviceCMYK itself (directly or through a `/ColorSpace`
|
||||
/// resource), whose initial colour is black, unlike an ICC CMYK space's all-zero white.
|
||||
fn device_cmyk(doc: &Document, operand: &Object, cs_res: &Dict) -> bool {
|
||||
let is_device = |o: &Object| match &*doc.resolve(o) {
|
||||
Object::Name(n) => matches!(n.as_slice(), b"DeviceCMYK" | b"CMYK"),
|
||||
Object::Array(a) => a.len() == 1 && a.first().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceCMYK" | b"CMYK")),
|
||||
_ => false,
|
||||
};
|
||||
match operand.as_name() {
|
||||
Some(n) => is_device(operand) || cs_res.get(n).is_some_and(is_device),
|
||||
None => is_device(operand),
|
||||
}
|
||||
}
|
||||
|
||||
fn r3(v: f64) -> Object {
|
||||
Object::Real((v * 1000.0).round() / 1000.0)
|
||||
}
|
||||
|
||||
/// The current fill and stroke spaces (None = a device space set by g/rg/k, which the
|
||||
/// converted operator sets again), with the q/Q stack: what one content stream leaves for the
|
||||
/// next on the same page.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct State {
|
||||
fill: Option<Space>,
|
||||
stroke: Option<Space>,
|
||||
stack: Vec<(Option<Space>, Option<Space>)>,
|
||||
}
|
||||
|
||||
/// Rewrite one content stream's colour operators, starting from `state` and leaving in it the
|
||||
/// state at the stream's end. `cs_res` is its `/ColorSpace` resource dictionary. Returns the
|
||||
/// new bytes when anything changed, and how many operators.
|
||||
fn rewrite(data: &[u8], state: &mut State, doc: &Document, cs_res: &Dict, settings: &ConvertColours) -> (Option<Vec<u8>>, usize) {
|
||||
let mut ops: Vec<Op> = parse(data).ops;
|
||||
let mut count = 0usize;
|
||||
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
|
||||
let State { fill, stroke, stack } = state;
|
||||
let numbers = |op: &Op| -> Vec<f64> { op.operands.iter().filter_map(Object::as_f64).collect() };
|
||||
let convert = |c: [f64; 3], is_stroke: bool| -> Op {
|
||||
Op::new(op_for(settings.target, is_stroke), components(settings.target, c, settings.preserve_black).into_iter().map(r3).collect())
|
||||
};
|
||||
for op in ops.drain(..) {
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
stack.push((fill.clone(), stroke.clone()));
|
||||
if stack.len() > 512 {
|
||||
stack.remove(0);
|
||||
}
|
||||
out.push(op);
|
||||
}
|
||||
b"Q" => {
|
||||
if let Some((f, s)) = stack.pop() {
|
||||
*fill = f;
|
||||
*stroke = s;
|
||||
}
|
||||
out.push(op);
|
||||
}
|
||||
b"cs" | b"CS" => {
|
||||
let is_stroke = op.op.as_slice() == b"CS";
|
||||
let space = op.operands.first().map(|o| space_of(doc, o, cs_res, 0)).unwrap_or(Space::Gray);
|
||||
let pattern = space == Space::Pattern;
|
||||
if is_stroke {
|
||||
*stroke = Some(space);
|
||||
} else {
|
||||
*fill = Some(space);
|
||||
}
|
||||
if pattern {
|
||||
// Patterns stay what they are (their own content is not converted here).
|
||||
out.push(op);
|
||||
} else {
|
||||
// The space's initial colour, now in the target: black (a tint at full,
|
||||
// DeviceCMYK's 0 0 0 1, ISO 32000-1 8.6.5.5).
|
||||
let space = if is_stroke { stroke.clone() } else { fill.clone() }.unwrap_or(Space::Gray);
|
||||
let initial = match space {
|
||||
Space::Tint { .. } => rgb(&space, &[1.0; 8]),
|
||||
Space::Cmyk if op.operands.first().is_some_and(|o| device_cmyk(doc, o, cs_res)) => rgb(&space, &[0.0, 0.0, 0.0, 1.0]),
|
||||
_ => rgb(&space, &[]),
|
||||
};
|
||||
out.push(convert(initial, is_stroke));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K" => {
|
||||
let is_stroke = op.op[0].is_ascii_uppercase();
|
||||
let space = match op.op.to_ascii_lowercase().as_slice() {
|
||||
b"g" => Space::Gray,
|
||||
b"rg" => Space::Rgb,
|
||||
_ => Space::Cmyk,
|
||||
};
|
||||
let already = matches!(
|
||||
(settings.target, &space),
|
||||
(ColourTarget::Gray, Space::Gray) | (ColourTarget::Rgb, Space::Rgb) | (ColourTarget::Cmyk, Space::Cmyk)
|
||||
);
|
||||
if is_stroke {
|
||||
*stroke = None;
|
||||
} else {
|
||||
*fill = None;
|
||||
}
|
||||
if already {
|
||||
out.push(op);
|
||||
} else {
|
||||
let c = rgb(&space, &numbers(&op));
|
||||
out.push(convert(c, is_stroke));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
b"sc" | b"scn" | b"SC" | b"SCN" => {
|
||||
let is_stroke = op.op[0].is_ascii_uppercase();
|
||||
let space = if is_stroke { stroke.clone() } else { fill.clone() };
|
||||
match space {
|
||||
Some(Space::Pattern) | None if op.operands.iter().any(|o| matches!(o, Object::Name(_))) => out.push(op),
|
||||
None => {
|
||||
// sc after g/rg/k: the device space those set; read the numbers by count.
|
||||
let n = numbers(&op);
|
||||
let space = match n.len() {
|
||||
1 => Space::Gray,
|
||||
4 => Space::Cmyk,
|
||||
_ => Space::Rgb,
|
||||
};
|
||||
out.push(convert(rgb(&space, &n), is_stroke));
|
||||
count += 1;
|
||||
}
|
||||
Some(Space::Pattern) => out.push(op),
|
||||
Some(space) => {
|
||||
out.push(convert(rgb(&space, &numbers(&op)), is_stroke));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => out.push(op),
|
||||
}
|
||||
}
|
||||
((count > 0).then(|| serialize_ops(&out)), count)
|
||||
}
|
||||
|
||||
/// Whether `space`'s colours are `target`'s (an indexed space's are its base's).
|
||||
fn in_target(space: &Space, target: ColourTarget) -> bool {
|
||||
match (space, target) {
|
||||
(Space::Gray, ColourTarget::Gray) | (Space::Rgb, ColourTarget::Rgb) | (Space::Cmyk, ColourTarget::Cmyk) => true,
|
||||
(Space::Indexed { base, .. }, _) => in_target(base, target),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the picture `s` is outside `target`: a stencil mask has no colours of its own (it
|
||||
/// paints with the fill colour, which is converted).
|
||||
fn needs_converting(doc: &Document, s: &Stream, target: ColourTarget) -> bool {
|
||||
let stencil = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
|
||||
!stencil && !s.dict.get(b"ColorSpace").is_some_and(|cs| in_target(&space_of(doc, cs, &Dict::default(), 0), target))
|
||||
}
|
||||
|
||||
fn stream_of(doc: &Document, r: ObjRef) -> Option<Stream> {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cs_resources(doc: &Document, resources: Option<&Dict>) -> Dict {
|
||||
resources.and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What a run keeps across pages: each XObject already handled, and the object that now
|
||||
/// stands for it (itself, or the converted copy made because a page not being converted draws
|
||||
/// it too); and the XObjects those other pages draw.
|
||||
struct Run {
|
||||
done: HashMap<ObjRef, ObjRef>,
|
||||
shared: HashSet<ObjRef>,
|
||||
}
|
||||
|
||||
/// The XObjects (forms and images, and those the forms draw), and appearance streams, that
|
||||
/// the pages not in `pages` draw: converting one of those in place would change those pages.
|
||||
fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet<ObjRef> {
|
||||
let mut seen: HashSet<ObjRef> = HashSet::new();
|
||||
let mut todo: Vec<Object> = Vec::new();
|
||||
let xobjects_of = |res: Option<&Object>, todo: &mut Vec<Object>| {
|
||||
if let Some(xo) = res
|
||||
.map(|r| doc.resolve(r))
|
||||
.and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)))
|
||||
.and_then(|x| x.as_dict().cloned())
|
||||
{
|
||||
todo.extend(xo.iter().map(|(_, o)| o.clone()));
|
||||
}
|
||||
};
|
||||
for (i, page) in page_list(doc).into_iter().enumerate() {
|
||||
if pages.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
xobjects_of(page.dict.get(b"Resources"), &mut todo);
|
||||
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
for a in annots {
|
||||
let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue };
|
||||
let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue };
|
||||
for (_, v) in ap.iter() {
|
||||
match &*doc.resolve(v) {
|
||||
Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())),
|
||||
_ => todo.push(v.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some(o) = todo.pop() {
|
||||
let Some(r) = o.as_ref() else { continue };
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let res = match &*doc.get(r) {
|
||||
Object::Stream(s) => s.dict.get(b"Resources").cloned(),
|
||||
_ => None,
|
||||
};
|
||||
xobjects_of(res.as_ref(), &mut todo);
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Store a converted XObject: in place, or, when another page draws `r` too, as a new object
|
||||
/// the caller points to instead. Returns the object that now stands for `r`.
|
||||
fn put(doc: &mut Document, run: &mut Run, r: ObjRef, obj: Object) -> ObjRef {
|
||||
let to = if run.shared.contains(&r) {
|
||||
doc.add(obj)
|
||||
} else {
|
||||
doc.set(r, obj);
|
||||
r
|
||||
};
|
||||
run.done.insert(r, to);
|
||||
to
|
||||
}
|
||||
|
||||
/// Convert the XObjects of `resources`: forms rewritten (and their own XObjects after them),
|
||||
/// images through `recolour`. Each object is converted once; one that a page not being
|
||||
/// converted draws is converted as a copy, and the returned `/XObject` dictionary, when there
|
||||
/// is one, points at the copies (the caller puts it in `resources`' place). When `may_copy` is
|
||||
/// false the caller cannot do that, so such objects are left alone.
|
||||
fn convert_xobjects(
|
||||
doc: &mut Document,
|
||||
resources: Option<&Dict>,
|
||||
settings: &ConvertColours,
|
||||
recolour: &mut ImageRecolour<'_>,
|
||||
run: &mut Run,
|
||||
result: &mut Converted,
|
||||
may_copy: bool,
|
||||
) -> Option<Dict> {
|
||||
let xo = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?;
|
||||
let mut repointed = xo.clone();
|
||||
let mut changed = false;
|
||||
for (name, o) in xo.iter() {
|
||||
let Some(r) = o.as_ref() else { continue };
|
||||
if let Some(&to) = run.done.get(&r) {
|
||||
if to != r {
|
||||
repointed.set(name.clone(), Object::Ref(to));
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !may_copy && run.shared.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
// Marked before converting, so a form that draws itself ends.
|
||||
run.done.insert(r, r);
|
||||
let Some(s) = stream_of(doc, r) else { continue };
|
||||
let stored = match s.dict.name(b"Subtype") {
|
||||
Some(b"Form") => {
|
||||
let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let cs = cs_resources(doc, res.as_ref());
|
||||
let (new, n) = match s.decoded() {
|
||||
Ok(data) => rewrite(&data, &mut State::default(), doc, &cs, settings),
|
||||
Err(_) => (None, 0),
|
||||
};
|
||||
let inner = convert_xobjects(doc, res.as_ref(), settings, recolour, run, result, true);
|
||||
if new.is_none() && inner.is_none() {
|
||||
None
|
||||
} else {
|
||||
let mut form = s.clone();
|
||||
if let Some(xo) = inner {
|
||||
let mut res = res.unwrap_or_default();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
form.dict.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
if let Some(bytes) = new {
|
||||
let mut dict = form.dict;
|
||||
for k in [&b"Length"[..], b"Filter", b"DecodeParms"] {
|
||||
dict.remove(k);
|
||||
}
|
||||
form = Stream::flate(dict, &bytes);
|
||||
result.colours += n;
|
||||
}
|
||||
Some(put(doc, run, r, Object::Stream(form)))
|
||||
}
|
||||
}
|
||||
Some(b"Image") if settings.images => match recolour(doc, &s, settings.target) {
|
||||
Some(new) => {
|
||||
result.images += 1;
|
||||
Some(put(doc, run, r, Object::Stream(new)))
|
||||
}
|
||||
None => {
|
||||
result.images_left += usize::from(needs_converting(doc, &s, settings.target));
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(to) = stored
|
||||
&& to != r
|
||||
{
|
||||
repointed.set(name.clone(), Object::Ref(to));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed.then_some(repointed)
|
||||
}
|
||||
|
||||
/// Convert the colours on `pages` (0-based). An object that pages not in `pages` also draw is
|
||||
/// converted as a copy for these pages, so the others keep their colours.
|
||||
pub fn convert_colours(
|
||||
doc: &mut Document,
|
||||
pages: &[usize],
|
||||
settings: &ConvertColours,
|
||||
recolour: &mut ImageRecolour<'_>,
|
||||
) -> Result<Converted, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
let mut result = Converted::default();
|
||||
let mut run = Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages) };
|
||||
for &i in pages {
|
||||
let Some(page) = page_list(doc).get(i).cloned() else { continue };
|
||||
let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
let cs = cs_resources(doc, resources.as_ref());
|
||||
let before = (result.colours, result.images);
|
||||
// Each content stream on its own (state carries across them); a page with a stream
|
||||
// that cannot be read keeps its content as it is.
|
||||
if let Some(streams) = PageStreams::read(doc, &page.dict) {
|
||||
let mut state = State::default();
|
||||
let mut new: Vec<Option<Vec<u8>>> = Vec::with_capacity(streams.data.len());
|
||||
for data in &streams.data {
|
||||
let (bytes, n) = rewrite(data, &mut state, doc, &cs, settings);
|
||||
result.colours += n;
|
||||
new.push(bytes);
|
||||
}
|
||||
streams.write(doc, page.obj, new)?;
|
||||
}
|
||||
if let Some(xo) = convert_xobjects(doc, resources.as_ref(), settings, recolour, &mut run, &mut result, true) {
|
||||
// This page's own resources point at the copies; a resource dictionary other pages
|
||||
// share is not changed.
|
||||
let mut res = resources.clone().unwrap_or_default();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
}
|
||||
if settings.annotations {
|
||||
let annots: Vec<ObjRef> = page
|
||||
.dict
|
||||
.get(b"Annots")
|
||||
.map(|a| doc.resolve(a))
|
||||
.and_then(|a| a.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|a| a.as_ref())
|
||||
.collect();
|
||||
for a in annots {
|
||||
let Some(ap) =
|
||||
doc.get(a).as_dict().and_then(|d| d.get(b"AP").cloned()).map(|ap| doc.resolve(&ap)).and_then(|ap| ap.as_dict().cloned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Every appearance stream (and the states of a /N dictionary) as a form.
|
||||
let mut streams: Vec<ObjRef> = Vec::new();
|
||||
for (_, v) in ap.iter() {
|
||||
match &*doc.resolve(v) {
|
||||
Object::Stream(_) => streams.extend(v.as_ref()),
|
||||
Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut fake = Dict::new();
|
||||
let mut xo = Dict::new();
|
||||
for (k, r) in streams.iter().enumerate() {
|
||||
xo.set(format!("A{k}").into_bytes(), Object::Ref(*r));
|
||||
}
|
||||
fake.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
// An appearance stream other pages draw is left alone (there is no resource
|
||||
// dictionary here to point at a copy).
|
||||
convert_xobjects(doc, Some(&fake), settings, recolour, &mut run, &mut result, false);
|
||||
}
|
||||
}
|
||||
if (result.colours, result.images) != before {
|
||||
result.pages += 1;
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! A replacement through the run sources must leave the rows it does not reach as they were.
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn page(content: &str) -> Document {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn content(doc: &Document) -> String {
|
||||
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let p = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
let list = match doc.resolve(p.dict.get(b"Contents").unwrap()).as_ref() {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![p.dict.get(b"Contents").unwrap().clone()],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for o in &list {
|
||||
if let Object::Stream(s) = &*doc.resolve(o) {
|
||||
out.extend(s.decoded().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_keeps_untouched_rows() {
|
||||
let mut doc = page("BT /F1 10 Tf 12 TL 100 700 Td [(Hello) -300 (World)] TJ T* (second row here) Tj ET");
|
||||
let block = text_blocks(&doc, 0).unwrap().swap_remove(0);
|
||||
let start = block.editor_text.find("here").expect("the second row's word is in the paragraph");
|
||||
let (text, sources) = block.replaced(std::slice::from_ref(&(start..start + 4)), "there").expect("the range is on boundaries");
|
||||
let style = BlockStyle { sources, ..Default::default() };
|
||||
rewrite_block(&mut doc, 0, 0, Some(&text), &style).unwrap();
|
||||
let after = content(&doc);
|
||||
assert!(after.contains("-300"), "the first row keeps its TJ kerning: {after}");
|
||||
}
|
||||
|
||||
/// The rewrite keeps every space typed, so a deleted word must not leave its two neighbouring
|
||||
/// spaces behind; a run of spaces the paragraph had is left as it was.
|
||||
#[test]
|
||||
fn a_deleted_word_leaves_one_space() {
|
||||
let doc = page("BT /F1 10 Tf 100 700 Td (one two three) Tj ET");
|
||||
let block = text_blocks(&doc, 0).unwrap().swap_remove(0);
|
||||
assert_eq!(block.editor_text, "one two three");
|
||||
let two = block.editor_text.find("two").unwrap();
|
||||
let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "").unwrap();
|
||||
assert_eq!(text, "one three");
|
||||
let one = block.editor_text.find("one").unwrap();
|
||||
let (text, _) = block.replaced(std::slice::from_ref(&(one..one + 3)), "").unwrap();
|
||||
assert_eq!(text, " two three");
|
||||
let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "2").unwrap();
|
||||
assert_eq!(text, "one 2 three");
|
||||
}
|
||||
@@ -61,6 +61,15 @@ impl PageImage {
|
||||
self.clips.iter().any(|clip| clip.app_crop)
|
||||
}
|
||||
|
||||
/// The editing frame, respecting explicit PDF/editor crops but not the page boundary.
|
||||
/// Selected handles remain reachable outside the page; body hits still use `contains`.
|
||||
pub fn frame_rect(&self) -> Option<[f64; 4]> {
|
||||
self.clips.iter().try_fold(self.rect, |rect, clip| {
|
||||
let b = clip.path.bounding_box();
|
||||
intersection(rect, [b.x0, b.y0, b.x1, b.y1])
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a user-space point belongs to the placed artwork and every active clip.
|
||||
pub fn contains(&self, point: [f64; 2]) -> bool {
|
||||
if !point.iter().all(|v| v.is_finite()) {
|
||||
|
||||
+10
-2
@@ -61,11 +61,17 @@ mod linebreak;
|
||||
pub mod paragraph_align;
|
||||
pub mod text;
|
||||
pub use text::{
|
||||
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
|
||||
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
|
||||
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, RunSources, TextBlock, TextLine, TextSession,
|
||||
block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block,
|
||||
text_blocks, text_lines,
|
||||
};
|
||||
pub mod printer_marks;
|
||||
pub use printer_marks::{PrinterMarks, add_printer_marks};
|
||||
mod colour;
|
||||
pub mod convert_colours;
|
||||
pub mod hairlines;
|
||||
mod page_streams;
|
||||
pub use convert_colours::{ColourTarget, ConvertColours, Converted as ColoursConverted, ImageRecolour, convert_colours};
|
||||
pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines};
|
||||
pub mod added;
|
||||
pub use added::{
|
||||
@@ -88,6 +94,8 @@ use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_
|
||||
use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint};
|
||||
pub use place::{add_type3_fonts, stamp, stamp_with_fonts, undrawable_message};
|
||||
|
||||
#[cfg(test)]
|
||||
mod find_replace_rows_tests;
|
||||
#[cfg(test)]
|
||||
mod marked_isolation_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -121,6 +121,7 @@ fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, rep
|
||||
MarkKind::Background => {
|
||||
add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace)
|
||||
}
|
||||
MarkKind::PrinterMarks => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +173,7 @@ fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_re
|
||||
MarkKind::HeaderFooter => "(New header)".to_string(),
|
||||
MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num),
|
||||
MarkKind::Background => "0.1 0.3 0.7 rg".to_string(),
|
||||
MarkKind::PrinterMarks => String::new(),
|
||||
};
|
||||
apply_replacement(&mut doc, kind, source, true).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
|
||||
@@ -77,6 +77,8 @@ pub enum MarkKind {
|
||||
HeaderFooter,
|
||||
Watermark,
|
||||
Background,
|
||||
/// Printer marks: trim, bleed, registration, colour bars, page information.
|
||||
PrinterMarks,
|
||||
}
|
||||
|
||||
impl MarkKind {
|
||||
@@ -85,6 +87,7 @@ impl MarkKind {
|
||||
MarkKind::HeaderFooter => "HeaderFooter",
|
||||
MarkKind::Watermark => "Watermark",
|
||||
MarkKind::Background => "Background",
|
||||
MarkKind::PrinterMarks => "PrinterMarks",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Printer marks (Acrobat's Print production ▸ Add Printer Marks): trim and
|
||||
//! bleed marks, registration targets, colour bars and page information drawn outside the trim
|
||||
//! box, as a page mark like a header, so they can be removed or replaced. The page is widened
|
||||
//! when asked and the marks would otherwise fall outside it; the trim box stays where it is.
|
||||
//!
|
||||
//! Lines, targets and the information line are in registration colour, the `/All` Separation
|
||||
//! (ISO 32000-2, 8.6.6.4), so they print on every plate, spot plates too; viewers show its
|
||||
//! four-ink alternate. The colour bars are process inks and stay DeviceCMYK.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_fonts::{literal, win_ansi};
|
||||
|
||||
use crate::marks::{MarkKind, n};
|
||||
use crate::place::{END, add_resources, begin, check, page_list, place};
|
||||
use crate::{EditError, remove_marks};
|
||||
|
||||
/// Which marks, and how heavy.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PrinterMarks {
|
||||
pub trim: bool,
|
||||
pub bleed: bool,
|
||||
pub registration: bool,
|
||||
pub color_bars: bool,
|
||||
pub page_info: bool,
|
||||
/// Line weight in points (Acrobat: 0.25, 0.5, 1).
|
||||
pub line_weight: f64,
|
||||
/// Widen the page by this many points on each side (at least what the marks need) when
|
||||
/// they would fall outside it; `None` leaves the page and clips the marks.
|
||||
pub make_room: Option<f64>,
|
||||
/// What the page information line says before the page number (the file's name).
|
||||
pub label: String,
|
||||
/// The date on the page information line.
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
impl Default for PrinterMarks {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
trim: true,
|
||||
bleed: true,
|
||||
registration: true,
|
||||
color_bars: true,
|
||||
page_info: true,
|
||||
line_weight: 0.25,
|
||||
make_room: Some(36.0),
|
||||
label: String::new(),
|
||||
date: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a mark reaches beyond the trim box: a gap, then the mark itself.
|
||||
const GAP: f64 = 6.0;
|
||||
const LENGTH: f64 = 18.0;
|
||||
/// Registration targets sit this far beyond the trim box, centred on each side.
|
||||
const TARGET_OFFSET: f64 = 16.0;
|
||||
const TARGET_RADIUS: f64 = 4.0;
|
||||
const BAR: f64 = 10.0;
|
||||
const INFO_SIZE: f64 = 7.0;
|
||||
/// The colour space resource holding registration colour.
|
||||
const REGISTRATION: &str = "PCReg";
|
||||
/// The largest page side PDF viewers take (Annex C of ISO 32000-1 lists it as Acrobat's limit).
|
||||
const MAX_PAGE_SIDE: f64 = 14_400.0;
|
||||
|
||||
/// `[/Separation /All /DeviceCMYK <tint>]`: full tint is all four process inks.
|
||||
fn registration() -> Object {
|
||||
let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
|
||||
let mut tint = Dict::new();
|
||||
tint.set(b"FunctionType".to_vec(), Object::Int(2));
|
||||
tint.set(b"Domain".to_vec(), reals(&[0.0, 1.0]));
|
||||
tint.set(b"C0".to_vec(), reals(&[0.0; 4]));
|
||||
tint.set(b"C1".to_vec(), reals(&[1.0; 4]));
|
||||
tint.set(b"N".to_vec(), Object::Int(1));
|
||||
Object::Array(vec![Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK"), Object::Dict(tint)])
|
||||
}
|
||||
|
||||
impl PrinterMarks {
|
||||
/// The line weights a run accepts, in points.
|
||||
pub const WEIGHTS: std::ops::RangeInclusive<f64> = 0.1..=4.0;
|
||||
/// The most a run widens a page by on each side at the caller's asking, in points.
|
||||
pub const MAX_ROOM: f64 = 288.0;
|
||||
|
||||
fn any(&self) -> bool {
|
||||
self.trim || self.bleed || self.registration || self.color_bars || self.page_info
|
||||
}
|
||||
|
||||
/// How far beyond the trim box the chosen marks reach.
|
||||
pub fn reach(&self) -> f64 {
|
||||
let mut r: f64 = 0.0;
|
||||
if self.trim || self.bleed {
|
||||
r = r.max(GAP + LENGTH);
|
||||
}
|
||||
if self.registration {
|
||||
r = r.max(TARGET_OFFSET + TARGET_RADIUS + 2.0);
|
||||
}
|
||||
if self.color_bars || self.page_info {
|
||||
r = r.max(GAP + BAR + 6.0);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The marks' content for a page whose trim box is `trim` and bleed box `bleed` (user
|
||||
/// space), `page` of `count` (1-based).
|
||||
pub fn content(&self, trim: [f64; 4], bleed: [f64; 4], page: usize, count: usize) -> String {
|
||||
let w = self.line_weight.clamp(*Self::WEIGHTS.start(), *Self::WEIGHTS.end());
|
||||
// Registration colour: every plate.
|
||||
let mut c = format!("/{REGISTRATION} CS 1 SCN /{REGISTRATION} cs 1 scn {} w 0 J\n", n(w));
|
||||
let [x0, y0, x1, y1] = trim;
|
||||
let line = |c: &mut String, ax: f64, ay: f64, bx: f64, by: f64| {
|
||||
c.push_str(&format!("{} {} m {} {} l S\n", n(ax), n(ay), n(bx), n(by)));
|
||||
};
|
||||
// Crop (trim) marks: at each corner, outside the box, along both edges.
|
||||
let corner_marks = |c: &mut String, b: [f64; 4]| {
|
||||
let [bx0, by0, bx1, by1] = b;
|
||||
for (x, dir) in [(bx0, -1.0), (bx1, 1.0)] {
|
||||
for y in [by0, by1] {
|
||||
// Vertical mark beyond the top or bottom edge.
|
||||
let (ya, yb) = if y == by0 { (y - GAP, y - GAP - LENGTH) } else { (y + GAP, y + GAP + LENGTH) };
|
||||
line(c, x, ya, x, yb);
|
||||
// Horizontal mark beyond the left or right edge.
|
||||
line(c, x + dir * GAP, y, x + dir * (GAP + LENGTH), y);
|
||||
}
|
||||
}
|
||||
};
|
||||
if self.trim {
|
||||
corner_marks(&mut c, trim);
|
||||
}
|
||||
if self.bleed && bleed != trim {
|
||||
c.push_str(&format!("[{} {}] 0 d\n", n(w * 4.0), n(w * 4.0)));
|
||||
corner_marks(&mut c, bleed);
|
||||
c.push_str("[] 0 d\n");
|
||||
}
|
||||
if self.registration {
|
||||
let r = TARGET_RADIUS;
|
||||
let k = 0.5523 * r;
|
||||
let target = |c: &mut String, cx: f64, cy: f64| {
|
||||
c.push_str(&format!(
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S\n",
|
||||
n(cx + r),
|
||||
n(cy),
|
||||
n(cx + r),
|
||||
n(cy + k),
|
||||
n(cx + k),
|
||||
n(cy + r),
|
||||
n(cx),
|
||||
n(cy + r),
|
||||
n(cx - k),
|
||||
n(cy + r),
|
||||
n(cx - r),
|
||||
n(cy + k),
|
||||
n(cx - r),
|
||||
n(cy),
|
||||
n(cx - r),
|
||||
n(cy - k),
|
||||
n(cx - k),
|
||||
n(cy - r),
|
||||
n(cx),
|
||||
n(cy - r),
|
||||
n(cx + k),
|
||||
n(cy - r),
|
||||
n(cx + r),
|
||||
n(cy - k),
|
||||
n(cx + r),
|
||||
n(cy)
|
||||
));
|
||||
line(c, cx - r * 1.6, cy, cx + r * 1.6, cy);
|
||||
line(c, cx, cy - r * 1.6, cx, cy + r * 1.6);
|
||||
};
|
||||
let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
target(&mut c, mx, y1 + TARGET_OFFSET);
|
||||
target(&mut c, mx, y0 - TARGET_OFFSET);
|
||||
target(&mut c, x0 - TARGET_OFFSET, my);
|
||||
target(&mut c, x1 + TARGET_OFFSET, my);
|
||||
}
|
||||
if self.color_bars {
|
||||
// Above the top edge, from the left: C, M, Y, K at 100%, then 75, 50, 25% K.
|
||||
let y = y1 + GAP + 2.0;
|
||||
let x = x0 + LENGTH + GAP + 12.0;
|
||||
let swatches: [(f64, f64, f64, f64); 7] = [
|
||||
(1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 0.0, 0.75),
|
||||
(0.0, 0.0, 0.0, 0.5),
|
||||
(0.0, 0.0, 0.0, 0.25),
|
||||
];
|
||||
for (i, (cy, m, yl, k)) in swatches.iter().enumerate() {
|
||||
c.push_str(&format!("{} {} {} {} k {} {} {} {} re f\n", n(*cy), n(*m), n(*yl), n(*k), n(x + i as f64 * BAR), n(y), n(BAR), n(BAR)));
|
||||
}
|
||||
c.push_str(&format!("/{REGISTRATION} cs 1 scn\n"));
|
||||
}
|
||||
if self.page_info {
|
||||
let mut info = String::new();
|
||||
if !self.label.trim().is_empty() {
|
||||
info.push_str(self.label.trim());
|
||||
info.push_str(" ");
|
||||
}
|
||||
info.push_str(&format!("{page} of {count}"));
|
||||
if !self.date.trim().is_empty() {
|
||||
info.push_str(" ");
|
||||
info.push_str(self.date.trim());
|
||||
}
|
||||
let info: String = info.chars().filter(|ch| pdfcraft_fonts::win_ansi_encodable(*ch)).collect();
|
||||
c.push_str(&format!("BT /PCHelv {} Tf 1 0 0 1 {} {} Tm ", n(INFO_SIZE), n(x0 + LENGTH + GAP + 12.0), n(y0 - GAP - BAR)));
|
||||
c.push_str(&ascii_literal(&literal(&win_ansi(&info))));
|
||||
c.push_str(" Tj ET\n");
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF literal string made of WinAnsi bytes, as ASCII text: bytes from 0x80 up are written as
|
||||
/// octal escapes, so they reach the content stream unchanged instead of being replaced when the
|
||||
/// bytes are turned into a `String`.
|
||||
fn ascii_literal(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
for &b in bytes {
|
||||
if b < 0x80 {
|
||||
out.push(char::from(b));
|
||||
} else {
|
||||
out.push_str(&format!("\\{b:03o}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How far the chosen marks reach beyond the trim box on each side (left, bottom, right, top):
|
||||
/// `reach`, or more where the bleed marks, which start at the bleed box, go further out.
|
||||
fn side_reach(marks: &PrinterMarks, trim: [f64; 4], bleed: [f64; 4]) -> [f64; 4] {
|
||||
let base = marks.reach();
|
||||
if !(marks.bleed && bleed != trim) {
|
||||
return [base; 4];
|
||||
}
|
||||
let beyond = |bleed_edge: f64, trim_edge: f64| base.max((trim_edge - bleed_edge).abs() + GAP + LENGTH);
|
||||
[beyond(bleed[0], trim[0]), beyond(bleed[1], trim[1]), beyond(bleed[2], trim[2]), beyond(bleed[3], trim[3])]
|
||||
}
|
||||
|
||||
/// Add printer marks to `pages` (0-based). With `replace`, marks already there are removed
|
||||
/// first. Returns the pages that were widened (media or crop box) to make room.
|
||||
pub fn add_printer_marks(doc: &mut Document, pages: &[usize], marks: &PrinterMarks, replace: bool) -> Result<Vec<usize>, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
if !marks.any() {
|
||||
return Err(EditError::Invalid("choose at least one kind of mark".into()));
|
||||
}
|
||||
if !PrinterMarks::WEIGHTS.contains(&marks.line_weight) {
|
||||
let (low, high) = (PrinterMarks::WEIGHTS.start(), PrinterMarks::WEIGHTS.end());
|
||||
return Err(EditError::Invalid(format!("the line weight must be from {low} to {high} points")));
|
||||
}
|
||||
if marks.make_room.is_some_and(|room| !(0.0..=PrinterMarks::MAX_ROOM).contains(&room)) {
|
||||
return Err(EditError::Invalid(format!("the room to make must be from 0 to {} points", PrinterMarks::MAX_ROOM)));
|
||||
}
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::PrinterMarks)?;
|
||||
}
|
||||
let count = all.len();
|
||||
let mut widened = Vec::new();
|
||||
for &i in pages {
|
||||
let [media, crop, bleed, trim, art] = pdfcraft_organize_boxes(doc, i)?;
|
||||
let side = side_reach(marks, trim, bleed);
|
||||
let needed = [trim[0] - side[0], trim[1] - side[1], trim[2] + side[2], trim[3] + side[3]];
|
||||
let within = |b: [f64; 4]| b[0] <= needed[0] && b[1] <= needed[1] && b[2] >= needed[2] && b[3] >= needed[3];
|
||||
// The crop box is what shows, so the marks need room inside it, not only inside the media box.
|
||||
if !within(crop)
|
||||
&& let Some(room) = marks.make_room
|
||||
{
|
||||
let new_media = if within(media) {
|
||||
None
|
||||
} else {
|
||||
let room = |s: f64| room.max(s);
|
||||
Some([
|
||||
media[0].min(trim[0] - room(side[0])),
|
||||
media[1].min(trim[1] - room(side[1])),
|
||||
media[2].max(trim[2] + room(side[2])),
|
||||
media[3].max(trim[3] + room(side[3])),
|
||||
])
|
||||
};
|
||||
if new_media.is_some_and(|m| m[2] - m[0] > MAX_PAGE_SIDE || m[3] - m[1] > MAX_PAGE_SIDE) {
|
||||
return Err(EditError::Invalid(format!("page {} would be wider than the {MAX_PAGE_SIDE} points a PDF page can be", i + 1)));
|
||||
}
|
||||
// Without a wider media box, only the crop box grows, to the marks and no further than the media box.
|
||||
let new_crop = match new_media {
|
||||
Some(m) => m,
|
||||
None => [
|
||||
crop[0].min(needed[0]).max(media[0]),
|
||||
crop[1].min(needed[1]).max(media[1]),
|
||||
crop[2].max(needed[2]).min(media[2]),
|
||||
crop[3].max(needed[3]).min(media[3]),
|
||||
],
|
||||
};
|
||||
let rect = |r: [f64; 4]| Object::Array(r.iter().map(|v| Object::Real(*v)).collect());
|
||||
let page = page_list(doc)[i].clone();
|
||||
doc.update_dict(page.obj, |d| {
|
||||
if let Some(m) = new_media {
|
||||
d.set(b"MediaBox".to_vec(), rect(m));
|
||||
}
|
||||
// The crop box shows the marks too. The trim, bleed and art boxes default to the
|
||||
// crop box and are clipped to the media box, so each is written out as it was in
|
||||
// effect, or it would move with the wider boxes and a second run would widen the
|
||||
// page again.
|
||||
d.set(b"CropBox".to_vec(), rect(new_crop));
|
||||
for (key, value) in [(&b"BleedBox"[..], bleed), (&b"TrimBox"[..], trim), (&b"ArtBox"[..], art)] {
|
||||
d.set(key.to_vec(), rect(value));
|
||||
}
|
||||
})?;
|
||||
widened.push(i);
|
||||
}
|
||||
let page = page_list(doc)[i].clone();
|
||||
let content = marks.content(trim, bleed, i + 1, count);
|
||||
let body = format!("{}{}{}", begin(MarkKind::PrinterMarks, "PrinterMarks", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), content, END);
|
||||
add_resources(doc, &page, None, Some(body.as_bytes()))?;
|
||||
let page = page_list(doc)[i].clone();
|
||||
// add_resources gave the page resources of its own.
|
||||
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut spaces = res.get(b"ColorSpace").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default();
|
||||
spaces.set(REGISTRATION.as_bytes().to_vec(), registration());
|
||||
res.set(b"ColorSpace".to_vec(), Object::Dict(spaces));
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
let page = page_list(doc)[i].clone();
|
||||
place(doc, &page, MarkKind::PrinterMarks, body.into_bytes(), false)?;
|
||||
}
|
||||
Ok(widened)
|
||||
}
|
||||
|
||||
/// A page's boxes: media, crop, bleed, trim, art (each inherited or falling back as the
|
||||
/// standard says), without depending on the organize crate.
|
||||
fn pdfcraft_organize_boxes(doc: &Document, page: usize) -> Result<[[f64; 4]; 5], EditError> {
|
||||
let p = page_list(doc).get(page).cloned().ok_or_else(|| EditError::Invalid(format!("page {} does not exist", page + 1)))?;
|
||||
let rect = |k: &[u8]| -> Option<[f64; 4]> {
|
||||
let a = p.dict.get(k).map(|o| doc.resolve(o))?;
|
||||
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
|
||||
};
|
||||
let media = rect(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]);
|
||||
let clip = |r: [f64; 4]| [r[0].max(media[0]), r[1].max(media[1]), r[2].min(media[2]), r[3].min(media[3])];
|
||||
let crop = rect(b"CropBox").map(clip).unwrap_or(media);
|
||||
let (bleed, trim, art) = (rect(b"BleedBox").map(clip), rect(b"TrimBox").map(clip), rect(b"ArtBox").map(clip));
|
||||
Ok([media, crop, bleed.unwrap_or(crop), trim.unwrap_or(crop), art.unwrap_or(crop)])
|
||||
}
|
||||
@@ -26,6 +26,10 @@ pub fn page_rules(doc: &Document, page: usize) -> Result<Vec<[f64; 4]>, EditErro
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
|
||||
let streams = crate::text::content_streams(doc, &p.dict)?;
|
||||
// Unread content can change the graphics state used by every later stream.
|
||||
if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) {
|
||||
return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into()));
|
||||
}
|
||||
let pieces = crate::text::joined(&streams);
|
||||
let ops = pieces.parse().ops;
|
||||
|
||||
|
||||
+871
-19
@@ -2109,6 +2109,229 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual mixed-script rewrite timing probe; run with --ignored --nocapture"]
|
||||
fn mixed_script_paragraph_rewrite_timing_probe() {
|
||||
assert!(pdfcraft_fonts::document_japanese_font().is_some(), "the timing probe requires the release-pinned craft-fonts input");
|
||||
for (repeats, width) in [(32, 480.0), (512, 480.0), (2048, 480.0), (512, 14_400.0)] {
|
||||
let draft = "The quick brown fox шалом ".repeat(repeats);
|
||||
let mut samples = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (The quick brown fox) Tj ET");
|
||||
let style = text::BlockStyle {
|
||||
width: Some(width),
|
||||
align: Some(added::Align::Center),
|
||||
underline: Some(true),
|
||||
char_spacing: Some(1.25),
|
||||
scale: Some(80.0),
|
||||
..Default::default()
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
|
||||
samples.push(start.elapsed().as_micros());
|
||||
assert!(text::text_lines(&doc, 0).unwrap().iter().any(|line| line.base_font == "Helvetica"));
|
||||
std::hint::black_box(doc);
|
||||
}
|
||||
samples.sort_unstable();
|
||||
eprintln!("mixed-script-rewrite bytes={} width={width} median_us={} samples_us={samples:?}", draft.len(), samples[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_source_measurements_are_bounded_by_distinct_characters() {
|
||||
if without_craft_fonts("mixed_script_source_measurements_are_bounded_by_distinct_characters") {
|
||||
return;
|
||||
}
|
||||
for resource in ["F1", "F2"] {
|
||||
for repeats in [1, 256] {
|
||||
for width in [80.0, 14_400.0] {
|
||||
let draft = "ab шалом ab ".repeat(repeats);
|
||||
let distinct = draft.chars().collect::<std::collections::HashSet<_>>().len();
|
||||
let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET"));
|
||||
let style = text::BlockStyle { width: Some(width), align: Some(added::Align::Justify), underline: Some(true), ..Default::default() };
|
||||
text::take_source_character_measurements();
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
|
||||
let measured = text::take_source_character_measurements();
|
||||
assert_eq!(measured, distinct, "{resource}, {repeats} repeats, width {width}: measure each distinct source candidate once");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_cached_widths_follow_each_rewrite_state() {
|
||||
if without_craft_fonts("mixed_script_cached_widths_follow_each_rewrite_state") {
|
||||
return;
|
||||
}
|
||||
let draft = "ab шалом ab шалом ab шалом ab";
|
||||
for (resource, size, spacing, scale) in [("F2", 12.0, 0.0, 100.0), ("F1", 18.0, 1.25, 80.0), ("F2", 18.0, 1.25, 80.0), ("F1", 12.0, 0.0, 100.0)] {
|
||||
let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td (ab) Tj ET"));
|
||||
let original_font = doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref().clone();
|
||||
let style = text::BlockStyle {
|
||||
size: Some(size),
|
||||
width: Some(120.0),
|
||||
align: Some(added::Align::Center),
|
||||
underline: Some(true),
|
||||
char_spacing: Some(spacing),
|
||||
scale: Some(scale),
|
||||
..Default::default()
|
||||
};
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref(), &original_font);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::<Vec<_>>(), draft.split_whitespace().collect::<Vec<_>>());
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
let fonts = doc.dict(doc.dict(page.dict.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap();
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
let underlines: Vec<_> = ops.iter().filter(|op| op.is("m") || op.is("l")).collect();
|
||||
let rows: Vec<_> = lines.chunk_by(|a, b| (a.origin_baseline() - b.origin_baseline()).abs() < 0.001).collect();
|
||||
assert!(rows.len() > 1, "the cache must also be used while wrapping");
|
||||
assert_eq!(underlines.len(), rows.len() * 2);
|
||||
for (row, underline) in rows.into_iter().zip(underlines.as_chunks::<2>().0) {
|
||||
// Recompute from the saved fonts, independently of the rewrite's cache.
|
||||
let widths: Vec<_> = row
|
||||
.iter()
|
||||
.map(|line| {
|
||||
if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) {
|
||||
assert_eq!(line.font, resource);
|
||||
}
|
||||
let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap());
|
||||
let bytes = m.encode(&line.text).unwrap_or_else(|| panic!("saved font {} cannot encode {line:?}", m.base_font));
|
||||
m.codes(&bytes).iter().map(|(code, _)| m.width(*code) * size + spacing).sum::<f64>() * scale / 100.0
|
||||
})
|
||||
.collect();
|
||||
let total: f64 = widths.iter().sum();
|
||||
let left = (120.0 - total).max(0.0) / 2.0;
|
||||
let mut x = 72.0 + left;
|
||||
for (line, advance) in row.iter().zip(widths) {
|
||||
assert!((line.baseline()[0] - x).abs() < 0.02, "{resource}, size {size}: {line:?}, expected {x}");
|
||||
x += advance;
|
||||
}
|
||||
assert!((underline[0].operands[0].as_f64().unwrap() - left).abs() < 0.02);
|
||||
assert!((underline[1].operands[0].as_f64().unwrap() - left - total).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break() {
|
||||
if without_craft_fonts("mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break") {
|
||||
return;
|
||||
}
|
||||
let original = "The quick brown fox jumps over the lazy dog";
|
||||
let ending = "and keeps running through the long field.";
|
||||
let draft = format!("{original} шалом {ending}");
|
||||
let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL 72 700 Td ({original}) Tj T* ({ending}) Tj ET"));
|
||||
let font = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref().clone();
|
||||
text::replace_block(&mut doc, 0, 0, &draft).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref(), &font);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.contains("The quick brown fox")), "{lines:#?}");
|
||||
for line in &lines {
|
||||
if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) {
|
||||
assert_eq!(line.base_font, "Helvetica", "Latin must retain its source font: {line:?}");
|
||||
}
|
||||
}
|
||||
assert!(lines.iter().any(|l| l.text.contains("шалом") && l.base_font != "Helvetica"));
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::<Vec<_>>(), draft.split_whitespace().collect::<Vec<_>>());
|
||||
let fallback = page_font_programs(&doc).into_iter().filter(|(subtype, _)| subtype == b"Type3").count();
|
||||
assert_eq!(fallback, 1, "the new letters share one fallback font");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_subset_spaces_remain_extractable_after_saving() {
|
||||
if without_craft_fonts("mixed_script_subset_spaces_remain_extractable_after_saving") {
|
||||
return;
|
||||
}
|
||||
for (resource, draft) in [("F2", "ab шалом ab"), ("F2", "шалом ab"), ("F2", "ab шалом"), ("F1", "ab шалом ab")] {
|
||||
for align in [added::Align::Left, added::Align::Center, added::Align::Right] {
|
||||
let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET"));
|
||||
let font_id = pdfcraft_cos::ObjRef::new(if resource == "F2" { 6 } else { 5 }, 0);
|
||||
let original_font = doc.get(font_id).as_ref().clone();
|
||||
let style =
|
||||
text::BlockStyle { width: Some(300.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap();
|
||||
for bytes in [write_full(&doc, &Default::default()).unwrap(), pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()] {
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
let lines = text::text_lines(&saved, 0).unwrap();
|
||||
let decoded: String = lines.iter().map(|line| line.text.as_str()).collect();
|
||||
assert_eq!(decoded, draft, "{resource}: edge spaces of source runs must be real extractable glyphs");
|
||||
assert_eq!(text::text_blocks(&saved, 0).unwrap()[0].text, draft);
|
||||
assert_eq!(saved.get(font_id).as_ref(), &original_font);
|
||||
for line in lines.iter().filter(|line| line.text.chars().any(|ch| ch.is_ascii_alphabetic())) {
|
||||
assert_eq!(line.font, resource, "the Latin letters retain their source font: {line:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixed-font fallback must preserve the refusal of unsupported per-row alignment.
|
||||
#[test]
|
||||
fn mixed_script_row_alignment_is_refused_without_changes() {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET");
|
||||
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let style = text::BlockStyle { width: Some(300.0), aligns: vec![Some(added::Align::Center)], ..Default::default() };
|
||||
let err = text::rewrite_block(&mut doc, 0, 0, Some("abcшаломdef"), &style).unwrap_err();
|
||||
assert!(err.to_string().contains("selected-row alignment"), "{err}");
|
||||
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_runs_use_source_and_fallback_advances_for_alignment() {
|
||||
if without_craft_fonts("mixed_script_runs_use_source_and_fallback_advances_for_alignment") {
|
||||
return;
|
||||
}
|
||||
for (resource, prefix, suffix) in [("F1", "abc", "def"), ("F2", "ab", "ab"), ("F1", "abc ", "def")] {
|
||||
// Rewriting normalizes Tw; measuring a source space must use that same state.
|
||||
let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td ({prefix}) Tj ET"));
|
||||
let draft = format!("{prefix}шалом{suffix}");
|
||||
let style = text::BlockStyle {
|
||||
width: Some(300.0),
|
||||
align: Some(added::Align::Center),
|
||||
char_spacing: Some(1.25),
|
||||
scale: Some(80.0),
|
||||
..Default::default()
|
||||
};
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft);
|
||||
assert_eq!((lines[0].font.as_str(), lines[2].font.as_str()), (resource, resource));
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
let resources = doc.dict(page.dict.get(b"Resources").unwrap()).unwrap();
|
||||
let fonts = doc.dict(resources.get(b"Font").unwrap()).unwrap();
|
||||
let advances: Vec<f64> = lines
|
||||
.iter()
|
||||
.zip([prefix, "шалом", suffix])
|
||||
.map(|(line, part)| {
|
||||
let font = doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap();
|
||||
let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &font);
|
||||
let bytes = m.encode(part).unwrap();
|
||||
(m.codes(&bytes).iter().map(|(c, _)| m.width(*c) * 12.0).sum::<f64>() + part.chars().count() as f64 * 1.25) * 0.8
|
||||
})
|
||||
.collect();
|
||||
let mut x = 72.0 + (300.0 - advances.iter().sum::<f64>()) / 2.0;
|
||||
for (line, width) in lines.iter().zip(advances) {
|
||||
assert!((line.baseline()[0] - x).abs() < 0.01, "{resource}: {line:?}, expected {x}");
|
||||
x += width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_paragraph_batches_only_missing_glyphs_and_refuses_atomically() {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET");
|
||||
// Cyrillic also uses bundled Inter in builds without craft-fonts.
|
||||
text::rewrite_block_with_limit(&mut doc, 0, 0, Some("Latin шалом text"), &Default::default(), 2).unwrap();
|
||||
assert_eq!(page_font_programs(&doc).iter().filter(|(subtype, _)| subtype == b"Type3").count(), 3);
|
||||
let before = write_full(&doc, &Default::default()).unwrap();
|
||||
assert!(text::replace_block(&mut doc, 0, 0, "Latin \u{FDFD} text").is_err());
|
||||
assert_eq!(write_full(&doc, &Default::default()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
|
||||
if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") {
|
||||
@@ -2127,7 +2350,33 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
|
||||
} else {
|
||||
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
|
||||
}
|
||||
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
|
||||
let doc = reopen(&doc);
|
||||
if paragraph {
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, replacement);
|
||||
assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text.contains("world") && l.base_font == "Times-Roman"));
|
||||
} else {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, replacement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subset missing spaces must still split source and fallback runs while aligning text.
|
||||
#[test]
|
||||
fn mixed_source_subset_without_space_keeps_fallback_runs_after_reopen() {
|
||||
let draft = "a қ b a қ b";
|
||||
for align in [added::Align::Left, added::Align::Center, added::Align::Justify] {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let original = doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref().clone();
|
||||
let style = text::BlockStyle { width: Some(36.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft);
|
||||
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref(), &original);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert!(lines.iter().any(|line| line.text.contains('a') && line.font == "F2"));
|
||||
assert!(lines.iter().any(|line| line.text.contains('b') && line.font == "F2"));
|
||||
assert!(lines.iter().any(|line| line.text.contains('қ') && line.base_font == "Inter-Regular"));
|
||||
assert!(lines.iter().any(|line| line.text.contains(' ') && line.base_font == "Inter-Regular"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2144,9 +2393,22 @@ fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() {
|
||||
assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}");
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/PCLat"), "{replacement}: {content}");
|
||||
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
assert_eq!(lines[0].text, replacement);
|
||||
assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew");
|
||||
let reopened = reopen(&doc);
|
||||
let lines = text::text_lines(&reopened, 0).unwrap();
|
||||
if paragraph {
|
||||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
|
||||
for ch in ['a', 'b'].into_iter().filter(|ch| replacement.contains(*ch)) {
|
||||
assert!(lines.iter().any(|line| line.text.contains(ch) && line.base_font == "ABCDEF+Arial"), "{lines:?}");
|
||||
}
|
||||
} else {
|
||||
assert_eq!(lines[0].text, replacement);
|
||||
}
|
||||
for ch in replacement.chars().filter(|ch| !ch.is_ascii()) {
|
||||
assert!(
|
||||
lines.iter().any(|line| line.text.contains(ch) && line.base_font == "Inter-Regular"),
|
||||
"the generated Inter face draws {ch}: {lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6844,6 +7106,142 @@ fn reading_follows_forms_however_their_resources_are_stored() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3503: a form XObject's `/BBox` clips its content, so text drawn outside it is not shown and
|
||||
/// should not be read either -- matching the renderer, which already honours the clip.
|
||||
#[test]
|
||||
fn form_bbox_clips_text_from_reading() {
|
||||
let page = stream("", "/Fm Do");
|
||||
let fm = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 50 50] /Resources << /Font << /F1 6 0 R >> >>",
|
||||
"BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET",
|
||||
);
|
||||
let doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Fm 5 0 R >> >> /Contents 4 0 R >>",
|
||||
&page,
|
||||
&fm,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]);
|
||||
let texts: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["INSIDE"]);
|
||||
}
|
||||
|
||||
/// #3503: a page's own `re`/`W`/`n` clip (not a form's `/BBox`) hides text outside it the same
|
||||
/// way, and `Q` lifts it again -- the clip is scoped to its `q`/`Q` like the rest of the graphics
|
||||
/// state, not a one-way switch.
|
||||
#[test]
|
||||
fn page_re_w_n_clip_hides_text_from_reading_until_q() {
|
||||
let page = stream(
|
||||
"",
|
||||
"q 0 0 50 50 re W n BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 100 100 Td (AFTER) Tj ET",
|
||||
);
|
||||
let doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
&page,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]);
|
||||
let texts: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["INSIDE", "AFTER"]);
|
||||
}
|
||||
|
||||
/// The text `reading_blocks` reads on a 400 × 400 pt page drawing `content` with Helvetica as
|
||||
/// `/F1`, and forms `/Fa` and `/Fb` (objects 6 and 7) with the given dictionary entries and content.
|
||||
fn read_clipped(content: &str, forms: [(&str, &str); 2]) -> Vec<String> {
|
||||
let font = "/Font << /F1 5 0 R >>";
|
||||
let resources = format!("<< {font} /XObject << /Fa 6 0 R /Fb 7 0 R >> >>");
|
||||
let form = |(dict, body): (&str, &str)| stream(&format!("/Type /XObject /Subtype /Form {dict} /Resources {resources}"), body);
|
||||
let doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources {resources} /Contents 4 0 R >>"),
|
||||
&stream("", content),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
&form(forms[0]),
|
||||
&form(forms[1]),
|
||||
]);
|
||||
text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect()
|
||||
}
|
||||
|
||||
/// #5075 review: a `/BBox` with anything but four numbers is no bound. Reading the numbers it
|
||||
/// happens to hold (`[0 /Bad 0 50 50]` as `[0 0 50 50]`) invented a clip and hid real text.
|
||||
#[test]
|
||||
fn a_malformed_form_bbox_clips_nothing() {
|
||||
let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET";
|
||||
for bbox in ["[0 /Bad 0 50 50]", "[0 0 50]", "[0 0 50 50 60]", "[0 (x) 50 50]"] {
|
||||
let texts = read_clipped("/Fa Do", [(&format!("/BBox {bbox}"), body), ("", "")]);
|
||||
assert_eq!(texts, ["INSIDE", "OUTSIDE"], "/BBox {bbox}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5075 review: a clipping path of several rectangles clips to all of them, not to the last one
|
||||
/// drawn (text seen through the first rectangle was hidden), and not to the box around them
|
||||
/// either (text in the gap between them was read, though nothing of it shows).
|
||||
#[test]
|
||||
fn a_clip_of_several_rectangles_keeps_text_in_each() {
|
||||
let content = "q 0 0 50 50 re 100 100 50 50 re W n BT /F1 12 Tf 5 5 Td (FIRST) Tj ET BT /F1 12 Tf 105 105 Td (SECOND) Tj ET \
|
||||
BT /F1 12 Tf 60 60 Td (GAP) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET Q";
|
||||
assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["FIRST", "SECOND"]);
|
||||
}
|
||||
|
||||
/// #5075 review: a form's `/BBox` clips however thin it is and wherever its matrix puts it. A box
|
||||
/// one point by 1e-10 was taken for no box (its area fell under a fixed threshold), and one moved
|
||||
/// 1e12 points away lost its area to rounding; either way the text outside it was read.
|
||||
#[test]
|
||||
fn a_thin_or_far_off_form_bbox_still_clips() {
|
||||
let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET";
|
||||
let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET";
|
||||
let sorted = |mut v: Vec<String>| {
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let thin = read_clipped(&format!("/Fa Do {after}"), [("/BBox [0 0 1 1e-10]", body), ("", "")]);
|
||||
assert_eq!(thin, ["AFTER"], "a thin /BBox");
|
||||
let far = read_clipped(&format!("/Fa Do {after}"), [("/Matrix [1 0 0 1 1e12 1e12] /BBox [0 0 100 100]", body), ("", "")]);
|
||||
assert_eq!(sorted(far), ["AFTER", "INSIDE"], "a /BBox 1e12 points away");
|
||||
}
|
||||
|
||||
/// #5075 review: a clip of no area has no inside, so nothing drawn under it shows (ISO 32000-2
|
||||
/// §8.5.4). It was taken for no clip at all, and everything under it was read.
|
||||
#[test]
|
||||
fn a_clip_of_no_area_shows_nothing() {
|
||||
let content = "q 0 0 100 0 re W n BT /F1 12 Tf 5 5 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 5 300 Td (AFTER) Tj ET";
|
||||
assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["AFTER"]);
|
||||
}
|
||||
|
||||
/// #5075 review: what shows is the intersection of every clip in force. A long run that crosses a
|
||||
/// page clip and, apart from it, a form's `/BBox` overlaps each of them but shows in neither, as it
|
||||
/// does not when one form's `/BBox` lies apart from the one of the form it is drawn in.
|
||||
#[test]
|
||||
fn disjoint_clips_show_nothing_between_them() {
|
||||
let run = "BT /F1 12 Tf 5 10 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET";
|
||||
let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET";
|
||||
// A page clip and a form's `/BBox` side by side.
|
||||
let page_clip = format!("q 0 0 50 50 re W n /Fa Do Q {after}");
|
||||
assert_eq!(read_clipped(&page_clip, [("/BBox [100 0 150 50]", run), ("", "")]), ["AFTER"]);
|
||||
// A form's `/BBox` and the `/BBox` of the form inside it, side by side.
|
||||
let nested = format!("/Fa Do {after}");
|
||||
assert_eq!(read_clipped(&nested, [("/BBox [0 0 50 50]", "/Fb Do"), ("/BBox [100 0 150 50]", run)]), ["AFTER"]);
|
||||
}
|
||||
|
||||
/// #5075 review: a clip turned with its matrix keeps its turned shape. Its upright bounding box
|
||||
/// takes in corners it does not cover, and text there was read although it is clipped away.
|
||||
#[test]
|
||||
fn a_turned_clip_hides_text_in_the_corners_of_its_bounding_box() {
|
||||
// A 100 pt square turned 45° about (200, 0): a diamond with its lower corner at (200, 0).
|
||||
// Undone straight after the clip, so the text is drawn upright on the page.
|
||||
let (turn, undo) =
|
||||
("0.70710678 0.70710678 -0.70710678 0.70710678 200 0 cm", "0.70710678 -0.70710678 0.70710678 0.70710678 -141.421356 141.421356 cm");
|
||||
let texts = "BT /F1 6 Tf 132 3 Td (CORNER) Tj ET BT /F1 6 Tf 195 65 Td (CENTRE) Tj ET";
|
||||
let page_clip = format!("q {turn} 0 0 100 100 re W n {undo} {texts} Q");
|
||||
assert_eq!(read_clipped(&page_clip, [("", ""), ("", "")]), ["CENTRE"], "a turned page clip");
|
||||
// The same diamond as a form's `/BBox`, under its `/Matrix`.
|
||||
let matrix = "/Matrix [0.70710678 0.70710678 -0.70710678 0.70710678 200 0] /BBox [0 0 100 100]";
|
||||
assert_eq!(read_clipped("/Fa Do", [(matrix, &format!("{undo} {texts}")), ("", "")]), ["CENTRE"], "a turned form /BBox");
|
||||
}
|
||||
|
||||
/// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but
|
||||
/// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging.
|
||||
#[test]
|
||||
@@ -6958,24 +7356,28 @@ fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() {
|
||||
|
||||
/// Forms that draw themselves or each other, have no resources, a malformed matrix, or are
|
||||
/// missing are read once (or skipped) without panicking or looping.
|
||||
///
|
||||
/// `/BBox [0 0 20 420]` is wide enough to hold every form's own text below (#3503 clips a form's
|
||||
/// content to its `/BBox`; this test is about the loop and resource handling, not clipping).
|
||||
#[test]
|
||||
fn hostile_form_xobjects_are_read_once() {
|
||||
let page = stream("", "/Loop Do /A Do /NoRes Do /Bad Do /Missing Do /F1 Do");
|
||||
let font = "/Font << /F1 5 0 R >>";
|
||||
let lp = stream(
|
||||
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /Loop 6 0 R >> >>"),
|
||||
&format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /Loop 6 0 R >> >>"),
|
||||
"/Loop Do BT /F1 10 Tf 10 10 Td (once) Tj ET",
|
||||
);
|
||||
let a = stream(
|
||||
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /B 8 0 R >> >>"),
|
||||
&format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /B 8 0 R >> >>"),
|
||||
"/B Do BT /F1 10 Tf 10 100 Td (from a) Tj ET",
|
||||
);
|
||||
let b = stream(
|
||||
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /A 7 0 R >> >>"),
|
||||
&format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /A 7 0 R >> >>"),
|
||||
"/A Do BT /F1 10 Tf 10 200 Td (from b) Tj ET",
|
||||
);
|
||||
let nores = stream("/Subtype /Form /BBox [0 0 9 9]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET");
|
||||
let bad = stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET");
|
||||
let nores = stream("/Subtype /Form /BBox [0 0 20 420]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET");
|
||||
let bad =
|
||||
stream(&format!("/Subtype /Form /BBox [0 0 20 420] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET");
|
||||
let doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
@@ -7956,6 +8358,72 @@ fn unchanged_runs_between_two_edits_keep_their_styles() {
|
||||
assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer"));
|
||||
}
|
||||
|
||||
/// Find and replace: two words replaced in one paragraph of several runs. Each replacement takes
|
||||
/// the run of the first character it replaces, and every run between them keeps its own style.
|
||||
#[test]
|
||||
fn replacing_words_apart_in_a_paragraph_keeps_every_run_between_them() {
|
||||
let original = mixed_region_page();
|
||||
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
|
||||
assert_eq!(block.editor_text, "About\nPlain bold text.\nItalic footer");
|
||||
let about = block.editor_text.find("About").unwrap();
|
||||
let text_word = block.editor_text.find("text").unwrap();
|
||||
let (draft, sources) = block.replaced(&[about..about + 5, text_word..text_word + 4], "Info").unwrap();
|
||||
assert_eq!(draft, "Info\nPlain bold Info.\nItalic footer");
|
||||
let pieces: Vec<&str> = sources.iter().map(|(r, _)| &draft[r.clone()]).collect();
|
||||
assert_eq!(pieces, ["Info", "\nPlain ", "bold", " Info.", "\nItalic footer"]);
|
||||
let before = text::text_lines(&original, 0).unwrap();
|
||||
let source_of = |word: &str| before.iter().position(|l| l.text.contains(word)).unwrap();
|
||||
let runs: Vec<usize> = sources.iter().map(|(_, s)| *s).collect();
|
||||
assert_eq!(runs, [source_of("About"), source_of("Plain"), source_of("bold"), source_of(" text."), source_of("Italic")]);
|
||||
let mut doc = original.clone();
|
||||
let style = text::BlockStyle { sources, ..Default::default() };
|
||||
assert_eq!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).unwrap().substituted, None);
|
||||
let reopened = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[1].editor_text, draft);
|
||||
let lines = text::text_lines(&reopened, 0).unwrap();
|
||||
let look = |l: &text::TextLine| (l.base_font.clone(), l.color);
|
||||
let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("{word}: {lines:?}"));
|
||||
assert_eq!(look(run("Info")), look(&before[source_of("About")]), "the first replacement takes About's run");
|
||||
assert_eq!(look(run("Plain")), look(&before[source_of("Plain")]));
|
||||
assert_eq!(look(run("bold")), look(&before[source_of("bold")]), "the run between the two replacements keeps its style");
|
||||
assert_eq!(look(run("Info.")), look(&before[source_of(" text.")]));
|
||||
assert_eq!(look(run("Italic")), look(&before[source_of("Italic")]));
|
||||
// A replaced word that is a run of its own keeps that run's style, where aligning the old and
|
||||
// new text (the inline editor's way) gives a changed word the run before it.
|
||||
let bold = block.editor_text.find("bold").unwrap();
|
||||
let (draft, sources) = block.replaced(std::slice::from_ref(&(bold..bold + 4)), "heavy").unwrap();
|
||||
let mut doc = original.clone();
|
||||
text::rewrite_block(&mut doc, 0, 1, Some(&draft), &text::BlockStyle { sources, ..Default::default() }).unwrap();
|
||||
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
let heavy = lines.iter().find(|l| l.text.contains("heavy")).unwrap_or_else(|| panic!("{lines:?}"));
|
||||
assert_eq!(look(heavy), look(&before[source_of("bold")]), "the replacement keeps the bold run's style");
|
||||
// Out of order, overlapping or inside a character: no draft.
|
||||
assert!(block.replaced(&[text_word..text_word + 4, about..about + 5], "x").is_none());
|
||||
assert!(block.replaced(&[0..3, 2..5], "x").is_none());
|
||||
assert!(block.replaced(&[0..0, 3..4], "x").is_none(), "an empty range");
|
||||
assert_eq!(block.replaced(&[], "x").map(|(d, _)| d), Some(block.editor_text.clone()));
|
||||
}
|
||||
|
||||
/// Sources that don't cover the text in order, or name a run of another paragraph, are refused
|
||||
/// and change nothing.
|
||||
#[test]
|
||||
fn style_sources_must_cover_the_text_with_the_paragraphs_runs() {
|
||||
let original = mixed_region_page();
|
||||
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
|
||||
let draft = block.editor_text.replace("About", "Info");
|
||||
let own = block.lines[0];
|
||||
let other = text::text_blocks(&original, 0).unwrap()[0].lines[0];
|
||||
let len = draft.len();
|
||||
for sources in
|
||||
[vec![(0..4, own)], vec![(0..4, own), (5..len, own)], vec![(0..len, other)], vec![(0..len + 1, own)], vec![(4..len, own), (0..4, own)]]
|
||||
{
|
||||
let mut doc = original.clone();
|
||||
let style = text::BlockStyle { sources: sources.clone(), ..Default::default() };
|
||||
assert!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).is_err(), "{sources:?}");
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].editor_text, block.editor_text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() {
|
||||
let mut doc = mixed_region_page();
|
||||
@@ -9259,6 +9727,113 @@ fn removing_marks_strips_wrappers_that_closed_open_state() {
|
||||
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
|
||||
}
|
||||
|
||||
/// Printer marks: drawn outside the trim box as a removable page mark; the page
|
||||
/// is widened to make room when asked and the trim box stays; running again replaces them.
|
||||
#[test]
|
||||
fn printer_marks_are_drawn_outside_the_trim_box_and_removable() {
|
||||
use crate::printer_marks::PrinterMarks;
|
||||
let mut doc = fixture();
|
||||
let page_content_text = |doc: &Document, page: usize| String::from_utf8_lossy(&page_content_bytes(doc, page)).into_owned();
|
||||
let page = page_list(&doc)[0].clone();
|
||||
// A trim box inside a letter-size page, 36 pt in: room for the marks, bleed marks included
|
||||
// (the bleed box is 9 pt out, so they reach 33 pt beyond the trim box), without widening.
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 612.0, 792.0].iter().map(|v| Object::Real(*v)).collect()));
|
||||
d.set(b"TrimBox".to_vec(), Object::Array([36.0, 36.0, 576.0, 756.0].iter().map(|v| Object::Real(*v)).collect()));
|
||||
d.set(b"BleedBox".to_vec(), Object::Array([27.0, 27.0, 585.0, 765.0].iter().map(|v| Object::Real(*v)).collect()));
|
||||
})
|
||||
.unwrap();
|
||||
let marks = PrinterMarks { label: "Quote 1042.pdf".into(), date: "2026-10-13".into(), ..PrinterMarks::default() };
|
||||
let widened = add_printer_marks(&mut doc, &[0, 1], &marks, false).unwrap();
|
||||
assert_eq!(widened, vec![1], "page 2 has no trim box inside its media box, so it was widened");
|
||||
assert_eq!(marks_present(&doc), vec![MarkKind::PrinterMarks]);
|
||||
let text = page_content_text(&doc, 0);
|
||||
assert!(text.contains("/PCMark /PrinterMarks"), "{text}");
|
||||
// A trim mark at the lower-left corner: vertical below the box, horizontal to its left.
|
||||
assert!(text.contains("36 30 m 36 12 l S") && text.contains("30 36 m 12 36 l S"), "{text}");
|
||||
assert!(text.contains("/PCReg CS 1 SCN /PCReg cs 1 scn"), "registration colour: {text}");
|
||||
assert!(!text.contains("1 1 1 1 K"), "not four process inks alone: {text}");
|
||||
assert!(text.contains("[1 1] 0 d") && text.contains("27 21 m 27 3 l S"), "dashed bleed marks: {text}");
|
||||
assert!(text.contains("1 0 0 0 k") && text.contains("0 0 0 0.25 k"), "colour bars: {text}");
|
||||
assert!(text.contains("(Quote 1042.pdf 1 of 3 2026-10-13) Tj"), "page information: {text}");
|
||||
// Page 2: the media box grew by 36 pt a side around its (whole-page) trim box.
|
||||
let p2 = page_list(&doc)[1].clone();
|
||||
let media: Vec<f64> = p2.dict.get(b"MediaBox").and_then(|m| m.as_array()).unwrap().iter().filter_map(|v| v.as_f64()).collect();
|
||||
assert_eq!(media, vec![-36.0, -36.0, 636.0, 836.0]);
|
||||
// Again with replace: still one set of marks per page; without replace, two.
|
||||
add_printer_marks(&mut doc, &[0], &marks, true).unwrap();
|
||||
assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 1);
|
||||
add_printer_marks(&mut doc, &[0], &marks, false).unwrap();
|
||||
assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 2);
|
||||
assert_eq!(remove_marks(&mut doc, &[0, 1], MarkKind::PrinterMarks).unwrap(), 3);
|
||||
assert!(marks_present(&doc).is_empty());
|
||||
// Nothing chosen, or a bad weight, is refused.
|
||||
let none = PrinterMarks { trim: false, bleed: false, registration: false, color_bars: false, page_info: false, ..PrinterMarks::default() };
|
||||
assert!(add_printer_marks(&mut doc, &[0], &none, false).is_err());
|
||||
assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight: 0.0, ..PrinterMarks::default() }, false).is_err());
|
||||
}
|
||||
|
||||
/// Convert colours: device, ICC-based and separation colours in page content
|
||||
/// and forms become the target's operators; black is preserved; patterns stay; a second run
|
||||
/// finds nothing.
|
||||
#[test]
|
||||
fn colours_are_converted_to_gray_rgb_and_cmyk() {
|
||||
use crate::convert_colours::{ColourTarget, ConvertColours, convert_colours};
|
||||
let mut doc = fixture();
|
||||
let page = page_list(&doc)[0].clone();
|
||||
// An ICCBased RGB space and a spot colour (Separation to CMYK, Type 2 tint).
|
||||
let mut icc = Dict::new();
|
||||
icc.set(b"N".to_vec(), Object::Int(3));
|
||||
let icc_ref = doc.add(Object::Stream(Stream::flate(icc, b"not a real profile")));
|
||||
let mut fun = Dict::new();
|
||||
fun.set(b"FunctionType".to_vec(), Object::Int(2));
|
||||
fun.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)]));
|
||||
fun.set(b"C0".to_vec(), Object::Array(vec![Object::Int(0); 4]));
|
||||
fun.set(b"C1".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(1), Object::Int(0)]));
|
||||
fun.set(b"N".to_vec(), Object::Int(1));
|
||||
let mut cs = Dict::new();
|
||||
cs.set(b"CS0".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(icc_ref)]));
|
||||
cs.set(b"Spot".to_vec(), Object::Array(vec![Object::name("Separation"), Object::name("Red"), Object::name("DeviceCMYK"), Object::Dict(fun)]));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"ColorSpace".to_vec(), Object::Dict(cs));
|
||||
let content = b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S /CS0 cs 0 1 0 scn 0 0 20 20 re f /Spot cs 1 scn 30 30 20 20 re f 0 g 60 60 10 10 re f 0.5 g 70 70 5 5 re f";
|
||||
let c = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Ref(c));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
})
|
||||
.unwrap();
|
||||
let mut no_images = |_: &Document, _: &Stream, _: ColourTarget| None;
|
||||
let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// Grayscale.
|
||||
let mut gray = doc.clone();
|
||||
let done = convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap();
|
||||
assert_eq!((done.colours, done.pages), (6, 1), "{done:?}");
|
||||
let t = flat(&page_content_bytes(&gray, 0));
|
||||
assert!(t.contains("0.299 g 10 10 50 50 re f"), "red → its luma: {t}");
|
||||
assert!(t.contains("0.114 G 0 0 m 100 100 l S"), "blue stroke: {t}");
|
||||
assert!(t.contains("0 g 0 1 0 scn") || t.contains("0.587 g 0 0 20 20 re f"), "ICC green through its space: {t}");
|
||||
assert!(t.contains("0.587 g 0 0 20 20 re f"), "{t}");
|
||||
assert!(t.contains("0.299 g 30 30 20 20 re f"), "the spot's tint through its CMYK alternate (0 1 1 0 = red): {t}");
|
||||
assert!(t.contains("0 g 60 60 10 10 re f") && t.contains("0.5 g 70 70 5 5 re f"), "gray stays gray: {t}");
|
||||
assert!(!t.contains("/CS0 cs") && !t.contains("/Spot cs"), "named spaces are gone: {t}");
|
||||
assert_eq!(convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0, "nothing left");
|
||||
// CMYK with black preserved; without, black is still 100% K by the formula.
|
||||
let mut cmyk = doc.clone();
|
||||
let s = ConvertColours { target: ColourTarget::Cmyk, ..ConvertColours::default() };
|
||||
convert_colours(&mut cmyk, &[0], &s, &mut no_images).unwrap();
|
||||
let t = flat(&page_content_bytes(&cmyk, 0));
|
||||
assert!(t.contains("0 1 1 0 k 10 10 50 50 re f"), "red: {t}");
|
||||
assert!(t.contains("0 0 0 1 k 60 60 10 10 re f"), "black preserved: {t}");
|
||||
assert!(t.contains("0 0 0 0.5 k 70 70 5 5 re f"), "gray 0.5 → 50% K: {t}");
|
||||
// RGB: the spot becomes its red.
|
||||
let mut rgbd = doc.clone();
|
||||
convert_colours(&mut rgbd, &[0], &ConvertColours { target: ColourTarget::Rgb, ..ConvertColours::default() }, &mut no_images).unwrap();
|
||||
let t = flat(&page_content_bytes(&rgbd, 0));
|
||||
assert!(t.contains("1 0 0 rg 30 30 20 20 re f"), "{t}");
|
||||
assert!(t.contains("1 0 0 rg 10 10 50 50 re f"), "already RGB, kept: {t}");
|
||||
}
|
||||
|
||||
/// Fix hairlines: zero-width strokes and strokes thinner than the threshold on
|
||||
/// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider
|
||||
/// lines, and glyphs and patterns unless asked, are left alone.
|
||||
@@ -9437,6 +10012,107 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
|
||||
}
|
||||
|
||||
/// `'` moves to the next line before it shows its text, and `"` also sets the word and character
|
||||
/// spacing (ISO 32000-2 §9.4.3, Table 107). Rewriting a paragraph drawn with them drops the
|
||||
/// operator but must keep what it did: text after it, positioned from that line or using that
|
||||
/// spacing, stays exactly where and how it was. Both the single-style paragraph path and the
|
||||
/// region path (a draft with a line break) are checked.
|
||||
#[test]
|
||||
fn rewriting_a_paragraph_drawn_with_quote_operators_keeps_their_line_move_and_spacing() {
|
||||
let cases = [
|
||||
// The next line is placed relative to the line `'` moved to.
|
||||
"BT /F1 12 Tf 14 TL 72 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET",
|
||||
// The same with `"`, whose spacing the footer also inherits.
|
||||
"BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET",
|
||||
// Spacing set by `"` carries over into the next text object.
|
||||
"BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET",
|
||||
// A split text object: text before the paragraph stays first, then the `'` lines go.
|
||||
"BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -46 Td (Target line here) Tj (more target) ' 0 -100 Td (Footer stays put) Tj ET",
|
||||
// Split at a `'`: the rewrite's `Tm` there is already past its line move.
|
||||
"BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -32 Td (Target line here) ' (more target) ' 0 -100 Td (Footer stays put) Tj ET",
|
||||
];
|
||||
for content in cases {
|
||||
let footer = |doc: &Document| {
|
||||
let lines = text::text_lines(doc, 0).unwrap();
|
||||
let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}"));
|
||||
(f.rect, f.ink.clone())
|
||||
};
|
||||
let original = text_page(content);
|
||||
let before = footer(&original);
|
||||
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text.starts_with("Target")).unwrap();
|
||||
for draft in ["NEW TEXT", "NEW\nTEXT"] {
|
||||
let mut doc = original.clone();
|
||||
text::replace_block(&mut doc, 0, target, draft).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(footer(&doc), before, "{content} with {draft:?}");
|
||||
let texts: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert!(texts.iter().any(|t| t.contains("NEW")) && !texts.iter().any(|t| t.contains("Target")), "{content} with {draft:?}: {texts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`text::replace_line`] also drops copies of the line drawn on the same baseline (a fake-bold
|
||||
/// overprint). A copy drawn with `'` or `"` must leave that operator's line move and spacing
|
||||
/// behind (ISO 32000-2 §9.4.3, Table 107), like the line's own first operator does: text after
|
||||
/// it is positioned from that line or uses that spacing. Each copy is tried as the target, so the
|
||||
/// one drawn with the quote operator is both the replaced line and the dropped copy.
|
||||
#[test]
|
||||
fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() {
|
||||
let cases = [
|
||||
// The copy's `'` moves to the line the footer is placed from.
|
||||
(2, "BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET"),
|
||||
// The copy's `"` sets the spacing the footer inherits in the next text object.
|
||||
(2, "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET"),
|
||||
// Two copies, a `'` one and a `"` one: the footer takes the second's line and spacing.
|
||||
(
|
||||
3,
|
||||
"BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' ET BT 72.6 700 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET",
|
||||
),
|
||||
];
|
||||
for (drawn, content) in cases {
|
||||
let footer = |doc: &Document| {
|
||||
let lines = text::text_lines(doc, 0).unwrap();
|
||||
let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}"));
|
||||
(f.rect, f.ink.clone())
|
||||
};
|
||||
let original = text_page(content);
|
||||
let before = footer(&original);
|
||||
let targets: Vec<usize> =
|
||||
text::text_lines(&original, 0).unwrap().iter().enumerate().filter(|(_, l)| l.text == "Target line here").map(|(i, _)| i).collect();
|
||||
assert_eq!(targets.len(), drawn, "{content}");
|
||||
for target in targets {
|
||||
let mut doc = original.clone();
|
||||
text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(footer(&doc), before, "{content} replacing line {target}");
|
||||
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert!(texts.iter().any(|t| t == "NEW TEXT") && !texts.iter().any(|t| t.contains("Target")), "{content}: {texts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy dropped with the line it overprints moved the pen by its text: a run after it in its
|
||||
/// text object, placed by that pen, stays where it was (ISO 32000-2 §9.4.4).
|
||||
#[test]
|
||||
fn replacing_a_line_keeps_the_advance_of_a_dropped_copy() {
|
||||
for content in [
|
||||
"BT /F1 12 Tf 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) Tj [-20000] TJ (Far) Tj ET",
|
||||
"BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' [-20000] TJ (Far) Tj ET",
|
||||
] {
|
||||
let original = text_page(content);
|
||||
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Far").map(|l| l.rect);
|
||||
let before = place(&original).unwrap_or_else(|| panic!("Far is a line of its own in {content}"));
|
||||
// The line drawn first, so the one followed by Far is the copy.
|
||||
let target = text::text_lines(&original, 0).unwrap().iter().position(|l| l.text == "Target line here").unwrap();
|
||||
let mut doc = original.clone();
|
||||
text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert!(!texts.iter().any(|t| t.contains("Target")), "{content}: the copy went too: {texts:?}");
|
||||
assert_eq!(place(&doc), Some(before), "{content}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2512: a session's drafts are laid out from the document as it was opened, never from each
|
||||
/// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before.
|
||||
#[test]
|
||||
@@ -11794,16 +12470,19 @@ fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() {
|
||||
#[test]
|
||||
fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() {
|
||||
// қ requires the same bundled fallback for both drafts, without optional font inputs.
|
||||
for spacing in [0.0, 2.0] {
|
||||
let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let mut selected = plain.clone();
|
||||
let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() };
|
||||
text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap();
|
||||
text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap();
|
||||
let a = text::text_lines(&reopen(&plain), 0).unwrap();
|
||||
let b = text::text_lines(&reopen(&selected), 0).unwrap();
|
||||
assert_eq!(a[0].base_font, b[0].base_font);
|
||||
assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect);
|
||||
// Exercise both retained source runs and a wholly substituted subset.
|
||||
for resource in ["F1", "F2"] {
|
||||
for spacing in [0.0, 2.0] {
|
||||
let mut plain = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET"));
|
||||
let mut selected = plain.clone();
|
||||
let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() };
|
||||
text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap();
|
||||
text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap();
|
||||
let a = text::text_lines(&reopen(&plain), 0).unwrap();
|
||||
let b = text::text_lines(&reopen(&selected), 0).unwrap();
|
||||
assert_eq!(a[0].base_font, b[0].base_font);
|
||||
assert!(close(a[0].rect, b[0].rect), "{resource}, Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen.
|
||||
@@ -13363,6 +14042,124 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same
|
||||
/// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after
|
||||
/// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as
|
||||
/// deleting a paragraph does: the run must not jump onto the new text. A kept run before the
|
||||
/// paragraph splits the text object, and what reopens it keeps that run's advance too.
|
||||
#[test]
|
||||
fn rewriting_a_paragraph_keeps_later_runs_on_its_baseline_in_place() {
|
||||
let cases = [
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj 1 0 0 rg 3 Ts (Red) Tj ET", "Red"),
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// Several dropped runs: each one's advance is kept.
|
||||
("BT /F1 12 Tf 72 700 Td (Target ) Tj (wor) Tj (ds) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// A kept run before the paragraph on its baseline: the text object is split there, and
|
||||
// the pen it had reached is kept as well as the line matrix.
|
||||
("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target ) Tj (words) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// The same with horizontal scaling and a later line placed from the line matrix.
|
||||
("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Far"),
|
||||
("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Next"),
|
||||
// A `Q` puts back the text matrix its `q` saved: the `Tm` between them doesn't place `Far`.
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj q 1 0 0 1 300 300 Tm Q [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// A `Q` with nothing saved restores nothing: the advance is still kept.
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj Q [-20000] TJ (Far) Tj ET", "Far"),
|
||||
];
|
||||
for (content, run) in cases {
|
||||
let original = text_page(content);
|
||||
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == run).map(|l| l.rect);
|
||||
let before = place(&original).unwrap_or_else(|| panic!("{run} is a line of its own in {content}"));
|
||||
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "Target words").unwrap();
|
||||
for draft in ["NEW TEXT", "NEW\nTEXT"] {
|
||||
let mut doc = original.clone();
|
||||
text::replace_block(&mut doc, 0, target, draft).unwrap();
|
||||
let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("{run} is gone: {content} with {draft:?}"));
|
||||
assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical text advances down the column (ISO 32000-2 §9.4.4): runs dropped by a rewrite leave
|
||||
/// that advance, so a run later in the same text object stays where it was.
|
||||
#[test]
|
||||
fn rewriting_vertical_runs_keeps_a_later_run_in_its_column_in_place() {
|
||||
// The second with a horizontal scaling (`Tz`), which a vertical advance doesn't take.
|
||||
for content in [
|
||||
"BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET",
|
||||
"BT /V1 20 Tf 80 Tz 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET",
|
||||
] {
|
||||
vertical_runs_keep_a_later_run_in_place(content);
|
||||
}
|
||||
}
|
||||
|
||||
fn vertical_runs_keep_a_later_run_in_place(content: &str) {
|
||||
let original = vertical_page(content);
|
||||
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "C").map(|l| l.rect);
|
||||
let before = place(&original).unwrap_or_else(|| panic!("C is a line of its own: {:?}", text::text_lines(&original, 0)));
|
||||
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "AB").unwrap();
|
||||
for draft in [
|
||||
"D", "D
|
||||
E",
|
||||
] {
|
||||
let mut doc = original.clone();
|
||||
text::replace_block(&mut doc, 0, target, draft).unwrap();
|
||||
let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("C is gone with {draft:?}"));
|
||||
assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The advances read the glyphs without `/ActualText`, which can come to more text than the
|
||||
/// reading the edit was laid out from. Past the page's text budget that way, no advance is kept
|
||||
/// and the edit goes ahead, rather than refusing a page the reading allowed.
|
||||
#[test]
|
||||
fn dropped_advances_past_the_text_budget_keep_none_rather_than_refuse() {
|
||||
let doc = text_page("BT /F1 12 Tf 72 700 Td (Drop) Tj [-20000] TJ (Far) Tj ET");
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
let drop: std::collections::HashSet<usize> = ops.iter().position(|o| o.is("Tj")).into_iter().collect();
|
||||
let page = pdfcraft_model::pages(&doc)[0].dict.clone();
|
||||
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
assert_eq!(text::dropped_advances_within(&doc, &ops, &res, &drop, None).len(), 1, "the advance Far relies on");
|
||||
assert!(text::dropped_advances_within(&doc, &ops, &res, &drop, Some(2)).is_empty(), "too much text to read: none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_rules_refuse_unread_graphics_state() {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("UnsupportedFilter"));
|
||||
let unread = Stream::from_raw(dict, b"2 0 0 2 0 0 cm".to_vec());
|
||||
let damaged = damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm");
|
||||
for stream in [unread, damaged] {
|
||||
let mut doc = image_page_with("10 10 m 100 10 l S");
|
||||
let incomplete = doc.add(Object::Stream(stream));
|
||||
doc.update_dict(ObjRef::new(3, 0), |page| {
|
||||
page.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(incomplete), Object::Ref(ObjRef::new(4, 0))]));
|
||||
})
|
||||
.unwrap();
|
||||
assert!(page_rules(&doc, 0).is_err(), "later geometry cannot be trusted after unread graphics state");
|
||||
}
|
||||
assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_editing_frame_keeps_page_edge_outside_explicit_clips() {
|
||||
for (prefix, expected) in [
|
||||
("", [-30.0, -20.0, 70.0, 80.0]),
|
||||
("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
|
||||
("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
|
||||
] {
|
||||
let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q"));
|
||||
for doc in [&doc, &reopen(&doc)] {
|
||||
let im = images::page_images(doc, 0).unwrap().remove(0);
|
||||
assert_eq!(im.frame_rect(), Some(expected));
|
||||
assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop"));
|
||||
assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]);
|
||||
assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable");
|
||||
assert!(im.contains([10.0, 10.0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() {
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET");
|
||||
@@ -14030,6 +14827,61 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() {
|
||||
assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing");
|
||||
}
|
||||
|
||||
/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a
|
||||
/// read refused for want of it refuses the edit. The subset is read four times over its program
|
||||
/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read)
|
||||
/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of
|
||||
/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it.
|
||||
#[test]
|
||||
fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() {
|
||||
let mut program = legacy_thai_program();
|
||||
program.resize((16 << 20) - 192, 0);
|
||||
let edit = |unicode_len: usize| {
|
||||
let mut unicode = LEGACY_THAI_UNICODE.to_vec();
|
||||
unicode.resize(unicode_len, b' ');
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET");
|
||||
let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode);
|
||||
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
|
||||
let style = BlockStyle { width: Some(400.0), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).map(|_| ()).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(edit(256), Ok(()));
|
||||
let error = edit(257).unwrap_err();
|
||||
assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}");
|
||||
}
|
||||
|
||||
/// Only a read the allowance refuses leaves a font of the face unread for want of it. With the
|
||||
/// allowance spent to the byte as above, another font of the face, whose ToUnicode is an empty
|
||||
/// stream behind a filter nothing decodes (unread with any allowance), still has its program read,
|
||||
/// which the allowance refuses: the edit is refused, rather than going through without that font.
|
||||
#[test]
|
||||
fn a_font_of_the_face_unread_on_its_own_once_the_allowance_is_spent_still_refuses_the_edit() {
|
||||
let mut program = legacy_thai_program();
|
||||
program.resize((16 << 20) - 192, 0);
|
||||
let mut unicode = LEGACY_THAI_UNICODE.to_vec();
|
||||
unicode.resize(256, b' ');
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET");
|
||||
let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode);
|
||||
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
|
||||
// Not a subset, so its metrics read no program, only the ToUnicode.
|
||||
let mut other = legacy_thai_font(&mut doc, "THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE);
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
|
||||
other.set(b"ToUnicode".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new())))));
|
||||
let other = doc.add(Object::Dict(other));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |page| {
|
||||
let Some(Object::Dict(mut resources)) = page.get(b"Resources").cloned() else { panic!("fixture") };
|
||||
let Some(Object::Dict(mut fonts)) = resources.get(b"Font").cloned() else { panic!("fixture") };
|
||||
fonts.set(b"F2".to_vec(), Object::Ref(other));
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
})
|
||||
.unwrap();
|
||||
let style = BlockStyle { width: Some(400.0), ..Default::default() };
|
||||
let error = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).unwrap_err().to_string();
|
||||
assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}");
|
||||
}
|
||||
|
||||
/// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is
|
||||
/// shaped with its own layout like any source font, not by the legacy shaper (which would ask
|
||||
/// for Windows PUA alternates such a face doesn't need).
|
||||
|
||||
+68
-6
@@ -10,7 +10,7 @@
|
||||
//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted.
|
||||
//! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
|
||||
@@ -39,25 +39,27 @@ mod rewrite;
|
||||
#[cfg(test)]
|
||||
use blocks::wrap;
|
||||
pub use blocks::{
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, RunSources, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
replace_block, text_blocks,
|
||||
};
|
||||
use blocks::{kept_words, paragraph_separator, row_break};
|
||||
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
|
||||
use editor::{builtin_thai_choice, layout_draft};
|
||||
#[cfg(test)]
|
||||
pub(crate) use fallback::SOURCE_SPACE_CALLS;
|
||||
use fallback::{
|
||||
MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes,
|
||||
source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use fallback::{SOURCE_SPACE_CALLS, take_source_character_measurements};
|
||||
#[cfg(test)]
|
||||
use fallback::{add_type3_font, is_type3_fallback};
|
||||
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
|
||||
use interp::{Origin, Paint, union};
|
||||
#[cfg(test)]
|
||||
pub(crate) use lines::dropped_advances_within;
|
||||
pub(crate) use lines::{
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call,
|
||||
joined, resource_dict, rewritten_contents, splice,
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, dropped_advances,
|
||||
dropped_side_effects, first_op_after_damage, form_call, joined, reopen_text_object, resource_dict, rewritten_contents, splice,
|
||||
};
|
||||
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
|
||||
use lines::{page_dict, word_lines};
|
||||
@@ -155,6 +157,66 @@ impl TextLine {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The clip regions reading uses (#3503): intersected regions keep only what both cover,
|
||||
/// regions apart leave nothing, turned ones keep their shape, and touching is not showing.
|
||||
#[test]
|
||||
fn clip_regions_intersect_and_show_what_they_cover() {
|
||||
use interp::{intersect, region, shows};
|
||||
let square = |x: f64, y: f64| region(&Matrix::IDENTITY, [x, y, x + 10.0, y + 10.0]).unwrap();
|
||||
let area = |p: &[[f64; 2]]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum::<f64>().abs() / 2.0;
|
||||
// Squares overlapping by half each way share a quarter.
|
||||
let both = intersect(&square(0.0, 0.0), &square(5.0, 5.0));
|
||||
assert!((area(&both) - 25.0).abs() < 1e-9, "{both:?}");
|
||||
assert!(shows([6.0, 6.0, 7.0, 7.0], &both) && !shows([1.0, 1.0, 2.0, 2.0], &both));
|
||||
// Squares apart share nothing, so nothing shows, not even a box across both.
|
||||
let none = intersect(&square(0.0, 0.0), &square(20.0, 0.0));
|
||||
assert!(!shows([-100.0, -100.0, 100.0, 100.0], &none), "{none:?}");
|
||||
// Squares that meet only along an edge share no area either: a box across that edge
|
||||
// does not show (#5075 review).
|
||||
let edge = intersect(&square(0.0, 0.0), &square(10.0, 0.0));
|
||||
assert!(!shows([8.0, 2.0, 12.0, 4.0], &edge), "{edge:?}");
|
||||
// Nothing intersected with anything is nothing, whichever side it is on.
|
||||
assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&square(0.0, 0.0), &Vec::new())));
|
||||
assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&Vec::new(), &square(0.0, 0.0))));
|
||||
// A box touching an edge shares no area; one a hair inside does.
|
||||
assert!(!shows([10.0, 0.0, 12.0, 5.0], &square(0.0, 0.0)));
|
||||
assert!(shows([9.99, 0.0, 12.0, 5.0], &square(0.0, 0.0)));
|
||||
// A square turned 45° keeps its diamond shape: a box in the corner of its bounding box is
|
||||
// outside it, the centre is inside.
|
||||
let c = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let diamond = region(&Matrix([c, c, -c, c, 0.0, 0.0]), [0.0, 0.0, 10.0, 10.0]).unwrap();
|
||||
assert!(!shows([-7.0, 0.0, -6.5, 0.5], &diamond) && shows([-0.5, 6.5, 0.5, 7.5], &diamond));
|
||||
// A corner repeated (an edge of no length) does not hide what the region covers.
|
||||
let doubled = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
|
||||
assert!(shows([4.0, 4.0, 6.0, 6.0], &doubled));
|
||||
// A rectangle of no area is a region that covers nothing; a corner that isn't finite
|
||||
// gives no region to clip with at all.
|
||||
let flat = region(&Matrix::IDENTITY, [0.0, 0.0, 10.0, 0.0]).unwrap();
|
||||
assert!(!shows([-100.0, -100.0, 100.0, 100.0], &flat), "{flat:?}");
|
||||
assert!(region(&Matrix::IDENTITY, [0.0, 0.0, f64::INFINITY, 10.0]).is_none());
|
||||
// However thin, a rectangle with area keeps it, and so does one far from the origin.
|
||||
let thin = region(&Matrix::IDENTITY, [0.0, 0.0, 1.0, 1e-10]).unwrap();
|
||||
assert!(shows([0.25, 0.0, 0.75, 1.0], &thin) && !shows([0.25, 1.0, 0.75, 2.0], &thin));
|
||||
let far = region(&Matrix([1.0, 0.0, 0.0, 1.0, 1e12, 1e12]), [0.0, 0.0, 100.0, 100.0]).unwrap();
|
||||
assert!(shows([1e12 + 10.0, 1e12 + 10.0, 1e12 + 20.0, 1e12 + 20.0], &far) && !shows([1e12 + 200.0, 1e12, 1e12 + 300.0, 1e12 + 10.0], &far));
|
||||
}
|
||||
|
||||
/// A stream that sets clip after clip, each turned a little more, keeps its region bounded:
|
||||
/// past the corner cap a region becomes its bounding box, which still holds all it held.
|
||||
#[test]
|
||||
fn clip_after_clip_keeps_the_region_bounded() {
|
||||
use interp::{intersect, region, shows};
|
||||
let mut clip = region(&Matrix::IDENTITY, [-100.0, -100.0, 100.0, 100.0]).unwrap();
|
||||
for i in 1..=10_000 {
|
||||
let (s, c) = (f64::from(i) * 0.001).sin_cos();
|
||||
let turned = region(&Matrix([c, s, -s, c, 0.0, 0.0]), [-60.0, -60.0, 60.0, 60.0]).unwrap();
|
||||
clip = intersect(&clip, &turned);
|
||||
assert!(clip.len() <= 32 && clip.iter().flatten().all(|v| v.is_finite()), "clip {i}: {} corners", clip.len());
|
||||
}
|
||||
// Every square is centred on the origin, so the middle still shows.
|
||||
assert!(shows([-1.0, -1.0, 1.0, 1.0], &clip));
|
||||
}
|
||||
|
||||
fn block(text: &str, rect: [f64; 4]) -> TextBlock {
|
||||
TextBlock {
|
||||
text: text.into(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::interp::{Carry, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines};
|
||||
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, show_side_effects, splice, text_lines};
|
||||
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
|
||||
use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions};
|
||||
|
||||
@@ -61,14 +61,7 @@ impl TextBlock {
|
||||
let old: Vec<_> = self.editor_text.chars().collect();
|
||||
let new: Vec<_> = draft.char_indices().collect();
|
||||
let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect();
|
||||
let mut original = Vec::with_capacity(old.len());
|
||||
let mut span = 0;
|
||||
for (byte, _) in self.editor_text.char_indices() {
|
||||
while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) {
|
||||
span += 1;
|
||||
}
|
||||
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
|
||||
}
|
||||
let original = self.char_sources();
|
||||
let mut sources = Vec::with_capacity(new.len());
|
||||
let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::<Vec<_>>();
|
||||
let (old_words, new_words) = (slices(&old), slices(&chars));
|
||||
@@ -229,6 +222,79 @@ fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte ranges of a paragraph's text and the source run (an index into [`text_lines`]) each one
|
||||
/// keeps the style of, in order ([`TextBlock::spans`], [`BlockStyle::sources`]).
|
||||
pub type RunSources = Vec<(std::ops::Range<usize>, usize)>;
|
||||
|
||||
impl TextBlock {
|
||||
/// The source run (an index into [`text_lines`]) of each character of `editor_text`. A
|
||||
/// character no span covers (the line break between two rows, a space put between two runs)
|
||||
/// belongs to the run that follows it.
|
||||
fn char_sources(&self) -> Vec<usize> {
|
||||
let mut original = Vec::with_capacity(self.editor_text.len());
|
||||
let mut span = 0;
|
||||
for (byte, _) in self.editor_text.char_indices() {
|
||||
while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) {
|
||||
span += 1;
|
||||
}
|
||||
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
|
||||
}
|
||||
original
|
||||
}
|
||||
|
||||
/// `editor_text` with each of `ranges` (byte ranges in it, in order and not overlapping) put
|
||||
/// in `with`, and the source run each byte range of the result keeps the style of
|
||||
/// ([`BlockStyle::sources`]): every character outside the ranges its own, a replacement the
|
||||
/// run of the first character it replaces. Spaces a replacement puts side by side become
|
||||
/// one (deleting a word leaves one space between its neighbours), while a run of spaces the
|
||||
/// paragraph already had stays, since the rewrite keeps every space typed. `None` when a
|
||||
/// range is out of order, overlaps another or does not fall on character boundaries.
|
||||
pub fn replaced(&self, ranges: &[std::ops::Range<usize>], with: &str) -> Option<(String, RunSources)> {
|
||||
let mut end = 0;
|
||||
for r in ranges {
|
||||
if r.start < end || r.start >= r.end || self.editor_text.get(r.clone()).is_none() {
|
||||
return None;
|
||||
}
|
||||
end = r.end;
|
||||
}
|
||||
// One run per stretch of the result: a piece from the same run right after it joins it.
|
||||
fn extend(spans: &mut Vec<(std::ops::Range<usize>, usize)>, start: usize, len: usize, source: usize) {
|
||||
match spans.last_mut() {
|
||||
_ if len == 0 => {}
|
||||
Some((range, last)) if *last == source && range.end == start => range.end = start.saturating_add(len),
|
||||
_ => spans.push((start..start.saturating_add(len), source)),
|
||||
}
|
||||
}
|
||||
let mut draft = String::with_capacity(self.editor_text.len());
|
||||
let mut spans = Vec::new();
|
||||
let mut next = ranges.iter().peekable();
|
||||
// Right after a replacement: a space here would join one the draft ends with.
|
||||
let mut joined = false;
|
||||
let spacing = |c: char| c != '\n' && c != '\r' && paragraph_separator(c);
|
||||
for ((byte, ch), source) in self.editor_text.char_indices().zip(self.char_sources()) {
|
||||
if let Some(r) = next.peek()
|
||||
&& byte >= r.start
|
||||
{
|
||||
if byte == r.start {
|
||||
extend(&mut spans, draft.len(), with.len(), source);
|
||||
draft.push_str(with);
|
||||
}
|
||||
if byte.saturating_add(ch.len_utf8()) >= r.end {
|
||||
next.next();
|
||||
joined = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if std::mem::take(&mut joined) && spacing(ch) && draft.chars().next_back().is_some_and(spacing) {
|
||||
continue;
|
||||
}
|
||||
extend(&mut spans, draft.len(), ch.len_utf8(), source);
|
||||
draft.push(ch);
|
||||
}
|
||||
Some((draft, spans))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a listing of [`text_blocks`] records a paragraph so that it can be found again.
|
||||
///
|
||||
/// The index of a paragraph is its position in a list regrouped from geometry on every read, so
|
||||
@@ -315,13 +381,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
|
||||
}
|
||||
let mut replacement = Vec::new();
|
||||
if let Some(op) = ops.get(s.op) {
|
||||
if op.is("\"") {
|
||||
replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))]));
|
||||
replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))]));
|
||||
}
|
||||
if op.is("'") || op.is("\"") {
|
||||
replacement.push(Op::new("T*", vec![]));
|
||||
}
|
||||
replacement.extend(show_side_effects(op));
|
||||
}
|
||||
replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
|
||||
patches.insert(s.op, replacement);
|
||||
@@ -467,6 +527,12 @@ pub struct BlockStyle {
|
||||
/// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour
|
||||
/// (RGB 0–1) before the new text, hiding the scanned word beneath it.
|
||||
pub cover: Option<[f64; 3]>,
|
||||
/// The source run each byte range of the new text keeps the style of (indexes into
|
||||
/// [`text_lines`], as in [`TextBlock::spans`]), covering the text in order. Empty:
|
||||
/// [`TextBlock::edited_spans`] works it out by aligning the old and new text, where a changed
|
||||
/// word takes the run before it. Find and replace sets it ([`TextBlock::replaced`]): there a
|
||||
/// replacement takes the run of the text it replaces, however many matches the paragraph has.
|
||||
pub sources: RunSources,
|
||||
/// For an invisible paragraph whose formatting nobody asked to change: cover and redraw only
|
||||
/// the words the draft changes, and leave the rest as the scan shows them (#3313). The session
|
||||
/// sets this; a caller that sets it should also give `cover`.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::HashSet;
|
||||
use super::interp::Ts;
|
||||
use super::lines::page_dict;
|
||||
use super::{
|
||||
@@ -630,6 +631,21 @@ pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<T
|
||||
runs.into_iter().map(|(font, bytes, spacing)| Some(Type3Run { name: fallback.names.get(font)?.clone(), bytes, spacing })).collect()
|
||||
}
|
||||
|
||||
/// Consecutive source/fallback runs, without allocating a list of input-derived runs.
|
||||
pub(super) fn source_fallback_runs<'a>(mut text: &'a str, source_chars: Option<&'a HashSet<char>>) -> impl Iterator<Item = (bool, &'a str)> + 'a {
|
||||
std::iter::from_fn(move || {
|
||||
let first = text.chars().next()?;
|
||||
let source = source_chars.is_some_and(|chars| chars.contains(&first));
|
||||
let end = source_chars
|
||||
.and_then(|chars| text.char_indices().find(|(_, ch)| chars.contains(ch) != source).map(|(offset, _)| offset))
|
||||
.unwrap_or(text.len());
|
||||
// char_indices supplies UTF-8 boundaries; get also handles an invalid range safely.
|
||||
let run = text.get(..end)?;
|
||||
text = text.get(end..)?;
|
||||
Some((source, run))
|
||||
})
|
||||
}
|
||||
|
||||
/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose
|
||||
/// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep
|
||||
/// its mapping there after the subsetter dropped its outline.
|
||||
@@ -702,6 +718,25 @@ pub(crate) fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64,
|
||||
words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static SOURCE_CHARACTER_MEASUREMENTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_source_character_measurements() -> usize {
|
||||
SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.replace(0))
|
||||
}
|
||||
|
||||
/// An actually encodable source character in the normalized `Tw = 0` drawing state.
|
||||
pub(super) fn source_character_advance(metrics: &Metrics, ch: char, size: f64, char_spacing: f64) -> Option<f64> {
|
||||
#[cfg(test)]
|
||||
SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.set(count.get().saturating_add(1)));
|
||||
let mut utf8 = [0; 4];
|
||||
let bytes = metrics.encode(ch.encode_utf8(&mut utf8))?;
|
||||
Some(metrics.codes(&bytes).iter().map(|(code, _)| metrics.width(*code) * size + char_spacing).sum())
|
||||
}
|
||||
|
||||
pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result<Vec<Op>, EditError> {
|
||||
if let Some(bytes) = metrics.encode(text) {
|
||||
return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]);
|
||||
|
||||
@@ -218,9 +218,139 @@ pub(crate) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
|
||||
}
|
||||
|
||||
/// A convex part of the page, in page space: the corners of a convex polygon, in order. With fewer
|
||||
/// than three corners it covers nothing.
|
||||
pub(super) type Region = Vec<[f64; 2]>;
|
||||
|
||||
/// A clip, as the union of the convex regions it leaves visible: one for each rectangle of its
|
||||
/// path. With no parts it leaves nothing visible.
|
||||
pub(super) type Clip = Vec<Region>;
|
||||
|
||||
/// The most corners a clip region keeps. Each intersection with a four-cornered clip can add
|
||||
/// corners, so past this a region becomes its bounding box: still all it held, and a stream that
|
||||
/// sets clip after clip stays bounded.
|
||||
const MAX_REGION_CORNERS: usize = 32;
|
||||
|
||||
/// The most parts a clip keeps. Clips intersect part by part, so past this its parts become one,
|
||||
/// the box around them: still all they held, and the work stays bounded.
|
||||
const MAX_CLIP_PARTS: usize = 16;
|
||||
|
||||
/// `r` (`[x0, y0, x1, y1]`, either way round) under `m`, keeping its shape when `m` turns or
|
||||
/// skews it. A rectangle of no area is a region that covers nothing, however thin a rectangle
|
||||
/// with area is; `None` when a corner, or its measure, is not finite.
|
||||
pub(super) fn region(m: &Matrix, r: [f64; 4]) -> Option<Region> {
|
||||
let corners: Region = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])]
|
||||
.into_iter()
|
||||
.map(|(x, y)| {
|
||||
let (x, y) = m.apply(x, y);
|
||||
[x, y]
|
||||
})
|
||||
.collect();
|
||||
let finite = corners.iter().flatten().all(|v| v.is_finite()) && twice_area(&corners).is_finite() && perimeter(&corners).is_finite();
|
||||
finite.then(|| if encloses_area(&corners) { corners } else { Vec::new() })
|
||||
}
|
||||
|
||||
/// The polygon's signed area, doubled: positive when its corners run anticlockwise (y up).
|
||||
/// Measured from its first corner, so that far from the origin it keeps its precision.
|
||||
fn twice_area(p: &[[f64; 2]]) -> f64 {
|
||||
let Some(o) = p.first() else { return 0.0 };
|
||||
p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (a[0] - o[0]) * (b[1] - o[1]) - (b[0] - o[0]) * (a[1] - o[1])).sum()
|
||||
}
|
||||
|
||||
fn perimeter(p: &[[f64; 2]]) -> f64 {
|
||||
p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum()
|
||||
}
|
||||
|
||||
/// Whether the polygon encloses any area: its area against the square of its perimeter, so the
|
||||
/// answer is the same at any scale and a thin box still has one.
|
||||
fn encloses_area(p: &[[f64; 2]]) -> bool {
|
||||
let around = perimeter(p);
|
||||
p.len() >= 3 && twice_area(p).abs() > 1e-12 * around * around
|
||||
}
|
||||
|
||||
/// The four corners of the box around `points`.
|
||||
fn bounding<'a>(points: impl Iterator<Item = &'a [f64; 2]>) -> Region {
|
||||
let b = points.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])]);
|
||||
vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
|
||||
}
|
||||
|
||||
/// The part of `a` inside `b`, both convex (Sutherland-Hodgman: `a` cut by each edge of `b`). Clips
|
||||
/// in force intersect, so text shows only where all of them let it through. Regions that share no
|
||||
/// area (apart, or meeting only along an edge) leave an empty one, as does an empty `a` or `b`.
|
||||
pub(super) fn intersect(a: &Region, b: &Region) -> Region {
|
||||
if a.len() < 3 || b.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let turn = twice_area(b).signum();
|
||||
let mut out = a.clone();
|
||||
for (p, q) in b.iter().zip(b.iter().cycle().skip(1)) {
|
||||
// Positive on the side of the edge `p` → `q` that `b` lies on.
|
||||
let inside = |v: &[f64; 2]| turn * ((q[0] - p[0]) * (v[1] - p[1]) - (q[1] - p[1]) * (v[0] - p[0]));
|
||||
let cut = std::mem::take(&mut out);
|
||||
for (s, e) in cut.iter().zip(cut.iter().cycle().skip(1)) {
|
||||
let (ds, de) = (inside(s), inside(e));
|
||||
if ds >= 0.0 {
|
||||
out.push(*s);
|
||||
}
|
||||
// The edge crosses the line: `ds - de` is not zero, as one is negative and one is not.
|
||||
if (ds >= 0.0) != (de >= 0.0) {
|
||||
let t = ds / (ds - de);
|
||||
out.push([s[0] + t * (e[0] - s[0]), s[1] + t * (e[1] - s[1])]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !encloses_area(&out) {
|
||||
return Vec::new();
|
||||
}
|
||||
if out.len() > MAX_REGION_CORNERS {
|
||||
out = bounding(out.iter());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The clip that leaves visible only what both `a` and `b` do: each part of one intersected with
|
||||
/// each part of the other.
|
||||
pub(super) fn clip_within(a: &Clip, b: &Clip) -> Clip {
|
||||
let mut out: Clip = a.iter().flat_map(|p| b.iter().map(move |q| intersect(p, q))).filter(|r| !r.is_empty()).collect();
|
||||
if out.len() > MAX_CLIP_PARTS {
|
||||
out = vec![bounding(out.iter().flatten())];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the box `r` shows through `clip`: through any of its parts.
|
||||
pub(super) fn clip_shows(r: [f64; 4], clip: &Clip) -> bool {
|
||||
clip.iter().any(|part| shows(r, part))
|
||||
}
|
||||
|
||||
/// Whether the box `r` (`[x0, y0, x1, y1]`) and `region` share some area, by the separating axis
|
||||
/// test: two convex shapes are apart exactly when their shadows on some axis, one of the box's or
|
||||
/// a normal of one of the region's edges, do not overlap. Touching is not sharing.
|
||||
pub(super) fn shows(r: [f64; 4], region: &Region) -> bool {
|
||||
if region.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
let corners = [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]];
|
||||
let apart = |axis: [f64; 2]| {
|
||||
let shadow = |points: &[[f64; 2]]| {
|
||||
points.iter().map(|p| p[0] * axis[0] + p[1] * axis[1]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)))
|
||||
};
|
||||
let ((a0, a1), (b0, b1)) = (shadow(&corners), shadow(region));
|
||||
a1 <= b0 || b1 <= a0
|
||||
};
|
||||
// An edge of no length (two corners in one place) has no normal to test.
|
||||
let normals = region.iter().zip(region.iter().cycle().skip(1)).map(|(p, q)| [p[1] - q[1], q[0] - p[0]]).filter(|n| n[0] != 0.0 || n[1] != 0.0);
|
||||
!([[1.0, 0.0], [0.0, 1.0]].into_iter().chain(normals).any(apart))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Ts {
|
||||
pub(super) ctm: Matrix,
|
||||
/// The clip in force, if any, as what it leaves visible: the intersection of every
|
||||
/// rectangular content-stream clip (`re` `W`/`W*` `n`, ISO 32000-2 §8.5.4) and every enclosing
|
||||
/// form's `/BBox` ([`super::lines`]), each in the shape its matrix gives it. `q`/`Q` save and
|
||||
/// restore it along with the rest of `Ts`.
|
||||
pub(super) clip: Option<Clip>,
|
||||
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
|
||||
pub(super) fill: Vec<Op>,
|
||||
pub(super) font: Option<(Vec<u8>, Rc<Metrics>)>,
|
||||
@@ -506,6 +636,7 @@ impl Carry {
|
||||
pub(super) fn new() -> Self {
|
||||
let ts = Ts {
|
||||
ctm: Matrix::IDENTITY,
|
||||
clip: None,
|
||||
fill: Vec::new(),
|
||||
font: None,
|
||||
face: None,
|
||||
@@ -860,11 +991,57 @@ pub(super) fn interpret(
|
||||
// with no properties dict (a file saved before this was tracked, or by another writer) reads
|
||||
// as 1, the single space a rejoin always used before #2529.
|
||||
let mut soft_gap = 1usize;
|
||||
// A pending path of `x y w h re`s, and whether a `W`/`W*` has marked it as the path that `n`
|
||||
// turns into the new clip (ISO 32000-2 §8.5.4) -- the common "set a rectangular clip" idiom.
|
||||
// The path is `Some(None)` once one of its rectangles can't be read, and then clips nothing.
|
||||
// Each rectangle is a part of the clip; a path of more than `MAX_CLIP_PARTS` keeps the box
|
||||
// around them, which still holds them all. Any other operator between `re` and `n` ends the
|
||||
// idiom: the clip is not read then.
|
||||
let mut pending_clip: Option<Option<Vec<[f64; 4]>>> = None;
|
||||
let mut clip_pending = false;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
// A gap's space carries only into a `Tj` or `TJ` right after it.
|
||||
if !matches!(op.op.as_slice(), b"Tj" | b"TJ") {
|
||||
carry.gap_space = false;
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"re" => {
|
||||
let rect = op.nums::<4>().map(|[x, y, w, h]| [x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]);
|
||||
pending_clip = Some(match (pending_clip.take(), rect) {
|
||||
(None, Some(rect)) => Some(vec![rect]),
|
||||
(Some(Some(mut path)), Some(rect)) => {
|
||||
if path.len() >= MAX_CLIP_PARTS {
|
||||
path = vec![path.iter().fold(rect, |all, r| union(all, *r))];
|
||||
} else {
|
||||
path.push(rect);
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
b"W" | b"W*" => clip_pending = pending_clip.is_some(),
|
||||
b"n" => {
|
||||
if clip_pending && let Some(Some(path)) = pending_clip.take() {
|
||||
// A rectangle past what a matrix can hold leaves the clip unread. One of no
|
||||
// area is a part that shows nothing, so a clip of no area hides everything.
|
||||
let parts: Option<Clip> = path.iter().map(|r| region(&ts.ctm, *r)).collect();
|
||||
if let Some(parts) = parts {
|
||||
let new: Clip = parts.into_iter().filter(|part| !part.is_empty()).collect();
|
||||
ts.clip = Some(match &ts.clip {
|
||||
Some(old) => clip_within(old, &new),
|
||||
None => new,
|
||||
});
|
||||
}
|
||||
}
|
||||
pending_clip = None;
|
||||
clip_pending = false;
|
||||
}
|
||||
_ => {
|
||||
pending_clip = None;
|
||||
clip_pending = false;
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::fallback::{same_font, same_paint};
|
||||
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
|
||||
use super::interp::{Carry, Clip, Origin, Pen, Shown, TextState, Ts, clip_shows, clip_within, direction, interpret, region, to_frame, union};
|
||||
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
|
||||
|
||||
impl TextLine {
|
||||
@@ -115,6 +115,146 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a text-showing operator does besides showing text (ISO 32000-2 §9.4.3, Table 107): `'`
|
||||
/// moves to the next line (`T*`), and `"` first sets the word and character spacing (`aw Tw`,
|
||||
/// `ac Tc`). Text after it relies on both, so a dropped `'` or `"` leaves these in its place.
|
||||
pub(crate) fn show_side_effects(op: &Op) -> Vec<Op> {
|
||||
let mut out = Vec::new();
|
||||
if op.is("\"") {
|
||||
out.extend(op.num(0).map(|aw| Op::new("Tw", vec![pdfcraft_content::num(aw)])));
|
||||
out.extend(op.num(1).map(|ac| Op::new("Tc", vec![pdfcraft_content::num(ac)])));
|
||||
}
|
||||
if op.is("'") || op.is("\"") {
|
||||
out.push(Op::new("T*", vec![]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For a paragraph or region rewrite: what dropped operator `i` must leave behind
|
||||
/// ([`show_side_effects`]), so the text after it keeps its line and spacing. `split_at` is the
|
||||
/// operator the text object was split at: the rewrite's `Tm` and state there already include
|
||||
/// that operator's effects.
|
||||
pub(crate) fn dropped_side_effects(ops: &[Op], i: usize, keep: bool, split_at: Option<usize>) -> Vec<Op> {
|
||||
if keep || split_at == Some(i) {
|
||||
return Vec::new();
|
||||
}
|
||||
ops.get(i).map(show_side_effects).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// For a paragraph or region rewrite that splits a text object: what reopens it after the
|
||||
/// rewritten paragraph, so the text after it starts where it did. `tlm` and `tm` are the line and
|
||||
/// text matrices where the paragraph began, and `size` and `scale` the font size (`Tf`) and
|
||||
/// horizontal scaling (`Tz` / 100) in force there. A `Tm` restores the line matrix, which a later
|
||||
/// `Td` or `T*` moves from; a kept run before the paragraph on the same baseline had already
|
||||
/// advanced the pen beyond it (ISO 32000-2 §9.4.4), so a numeric `TJ` then moves the text matrix
|
||||
/// alone by that advance. Only an advance along a horizontal baseline is expressible that way;
|
||||
/// one that isn't (a vertical run's, or one at a zero size) leaves the pen at the line matrix.
|
||||
pub(crate) fn reopen_text_object(tlm: [f64; 6], tm: [f64; 6], size: Option<f64>, scale: f64) -> Vec<Op> {
|
||||
let n = pdfcraft_content::num;
|
||||
let mut out = vec![Op::new("BT", vec![]), Op::new("Tm", tlm.iter().map(|v| n(*v)).collect())];
|
||||
// tm = [1 0 0 1 tx ty] x tlm: solve for the advance (tx, ty) in text space.
|
||||
let (dx, dy) = (tm[4] - tlm[4], tm[5] - tlm[5]);
|
||||
let det = tlm[0] * tlm[3] - tlm[1] * tlm[2];
|
||||
let tx = (dx * tlm[3] - dy * tlm[2]) / det;
|
||||
let ty = (dy * tlm[0] - dx * tlm[1]) / det;
|
||||
let denominator = size.unwrap_or(0.0) * scale;
|
||||
let adjustment = -1000.0 * tx / denominator;
|
||||
if tx.is_finite() && ty.is_finite() && tx.abs() > 1e-9 && ty.abs() <= 1e-9 && denominator != 0.0 && adjustment.is_finite() {
|
||||
out.push(Op::new("TJ", vec![Object::Array(vec![n(adjustment)])]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For a paragraph or region rewrite: a numeric `TJ` per dropped show operator in `drop`, moving
|
||||
/// the text matrix as far as that operator's text did without drawing anything (as
|
||||
/// [`super::clear_block`] does). Text shown later in the same text object without its own `Td`
|
||||
/// or `Tm`, such as a run in another style or column on the same baseline, then stays where it
|
||||
/// was. Only an advance such a run relies on is kept: none when the text object ends, or its next
|
||||
/// kept run starts a line of its own (`Td`, `TD`, `Tm`, `T*`, `'`, `"`), first. An advance a `TJ`
|
||||
/// can't express (spacing at a zero font size) is left out. `resources` is the page's resource
|
||||
/// dictionary. This reads the glyphs without `/ActualText`, which can come to more text than the
|
||||
/// reading the edit was laid out from; a page whose text that way exceeds the page's text budget
|
||||
/// keeps no advances rather than refusing an edit the reading allowed.
|
||||
pub(crate) fn dropped_advances(doc: &Document, ops: &[Op], resources: &Dict, drop: &std::collections::HashSet<usize>) -> HashMap<usize, Op> {
|
||||
dropped_advances_within(doc, ops, resources, drop, None)
|
||||
}
|
||||
|
||||
/// [`dropped_advances`] with `text_left` in place of the page's text budget (tests make it small).
|
||||
pub(crate) fn dropped_advances_within(
|
||||
doc: &Document,
|
||||
ops: &[Op],
|
||||
resources: &Dict,
|
||||
drop: &std::collections::HashSet<usize>,
|
||||
text_left: Option<usize>,
|
||||
) -> HashMap<usize, Op> {
|
||||
let mut out = HashMap::new();
|
||||
let mut carry = Carry::new();
|
||||
if let Some(left) = text_left {
|
||||
carry.text_left = left;
|
||||
}
|
||||
let follows = kept_run_follows(ops, drop);
|
||||
let Ok(shown) = interpret(doc, ops, resources, &mut HashMap::new(), &mut carry, None, None) else {
|
||||
return out;
|
||||
};
|
||||
for s in shown {
|
||||
if !drop.contains(&s.op) || s.advance == 0.0 || !follows.get(s.op).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let denominator = s.state.size * s.state.scale;
|
||||
let adjustment = -1000.0 * s.advance / denominator;
|
||||
if denominator != 0.0 && adjustment.is_finite() {
|
||||
out.entry(s.op).or_insert_with(|| Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each operator: whether a run shown after it in its text object, and not in `drop`, is
|
||||
/// placed by the pen it left rather than by a line move of its own (see [`dropped_advances`]).
|
||||
/// A `Q` puts back the text matrices its `q` saved (as [`interpret`] reads it), so a line move
|
||||
/// between them doesn't count for the text after the `Q`. Leaving an advance in is harmless where
|
||||
/// nothing turns out to use it. One pass from the end, so a page of many dropped runs stays linear.
|
||||
fn kept_run_follows(ops: &[Op], drop: &std::collections::HashSet<usize>) -> Vec<bool> {
|
||||
// The `q` and `Q` that pair up: [`interpret`] ignores a `Q` with nothing saved, so only a
|
||||
// `Q` that restores something is a boundary here.
|
||||
let mut paired = vec![false; ops.len()];
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
if op.is("q") {
|
||||
open.push(i);
|
||||
} else if op.is("Q")
|
||||
&& let Some(q) = open.pop()
|
||||
{
|
||||
for at in [q, i] {
|
||||
if let Some(slot) = paired.get_mut(at) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![false; ops.len()];
|
||||
let mut follows = false;
|
||||
// What followed each `Q` still open (read backwards), restored at its `q`.
|
||||
let mut after_restore: Vec<bool> = Vec::new();
|
||||
for (i, op) in ops.iter().enumerate().rev() {
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = follows;
|
||||
}
|
||||
let pairs = paired.get(i).copied().unwrap_or(false);
|
||||
match op.op.as_slice() {
|
||||
b"Q" if pairs => {
|
||||
after_restore.push(follows);
|
||||
follows = false;
|
||||
}
|
||||
b"q" if pairs => follows = follows || after_restore.pop().unwrap_or(false),
|
||||
b"ET" | b"BT" | b"Td" | b"TD" | b"Tm" | b"T*" | b"'" | b"\"" => follows = false,
|
||||
b"Tj" | b"TJ" if !drop.contains(&i) => follows = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The page's streams joined into the one stream they are: an operator may span two of them.
|
||||
pub(crate) fn joined(streams: &[ContentStream]) -> Pieces {
|
||||
Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::<Vec<_>>())
|
||||
@@ -565,8 +705,9 @@ impl FormBudget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
|
||||
/// CTM at the `Do`.
|
||||
/// A form XObject a page draws: its object, content, resources, its matrix composed with the
|
||||
/// CTM at the `Do`, and the clip its `/BBox` makes (page space, under that same matrix, so a
|
||||
/// turned form keeps its turned box; a box of no area hides everything) if it has a valid one.
|
||||
pub(crate) struct FormCall {
|
||||
pub(crate) obj: Option<pdfcraft_cos::ObjRef>,
|
||||
pub(crate) data: Vec<u8>,
|
||||
@@ -575,6 +716,7 @@ pub(crate) struct FormCall {
|
||||
form: Arc<Object>,
|
||||
own: Option<Arc<Object>>,
|
||||
pub(crate) ctm: Matrix,
|
||||
bbox: Option<Clip>,
|
||||
}
|
||||
|
||||
impl FormCall {
|
||||
@@ -644,6 +786,12 @@ pub(crate) fn form_call(
|
||||
<[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite()))
|
||||
});
|
||||
let own = s.dict.get(b"Resources").filter(|r| matches!(r, Object::Ref(_))).map(|r| doc.resolve(r));
|
||||
// Four finite numbers, or no bound at all: a member that isn't one makes the box unknown, and
|
||||
// reading the numbers around it would invent a clip.
|
||||
let bbox = s.dict.get(b"BBox").map(|b| doc.resolve(b)).and_then(|b| {
|
||||
let values: Vec<f64> = b.as_array()?.iter().map(|x| doc.resolve(x).as_f64().filter(|v| v.is_finite())).collect::<Option<_>>()?;
|
||||
<[f64; 4]>::try_from(values).ok()
|
||||
});
|
||||
let data = match budget.decode.decode(s) {
|
||||
Ok(data) => data,
|
||||
Err(_) if budget.decode.remaining() == 0 => {
|
||||
@@ -653,7 +801,8 @@ pub(crate) fn form_call(
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
budget.visits = budget.visits.saturating_add(1);
|
||||
Some(FormCall { obj, data, form, own, ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) })
|
||||
let ctm = m.map_or(ctm, |m| Matrix(m).then(&ctm));
|
||||
Some(FormCall { obj, data, form, own, ctm, bbox: bbox.and_then(|b| region(&ctm, b)).map(|r| if r.is_empty() { Vec::new() } else { vec![r] }) })
|
||||
}
|
||||
|
||||
/// The lines a page shows as it reads, including text drawn by form XObjects (which
|
||||
@@ -684,6 +833,12 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
|
||||
// Text after a form starts a new line.
|
||||
last = None;
|
||||
}
|
||||
// A run that the clips in force (forms' `/BBox`es and content-stream `re`/`W`/`n`
|
||||
// clips, intersected) hide is not drawn, so it is not read either (#3503).
|
||||
if s.state.clip.as_ref().is_some_and(|clip| !clip_shows(s.rect, clip)) {
|
||||
last = None;
|
||||
continue;
|
||||
}
|
||||
let si = stream(s.op);
|
||||
// Text from another stream starts a new line, as when each stream was read alone.
|
||||
if last_stream.replace(si).is_some_and(|prev| prev != si) {
|
||||
@@ -701,8 +856,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
|
||||
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) };
|
||||
let ops = parse(&form.data).ops;
|
||||
// The form starts with the graphics state at its `Do`, its own matrix applied.
|
||||
// Its content is clipped to its `/BBox` within whatever clips the page had in force at the
|
||||
// `Do`. It starts with a stack of its own, so no `Q` of its own can lift that clip.
|
||||
let clip = match (&ts.clip, &form.bbox) {
|
||||
(Some(outer), Some(own)) => Some(clip_within(outer, own)),
|
||||
(outer, own) => outer.clone().or_else(|| own.clone()),
|
||||
};
|
||||
let mut carry = Carry {
|
||||
ts: Ts { ctm: form.ctm, ..ts },
|
||||
ts: Ts { ctm: form.ctm, clip, ..ts },
|
||||
stack: Vec::new(),
|
||||
marked: Vec::new(),
|
||||
layers: Vec::new(),
|
||||
|
||||
+105
-44
@@ -1,13 +1,17 @@
|
||||
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
|
||||
use super::fallback::{
|
||||
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font,
|
||||
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
source_character_advance, source_encodes, source_fallback_runs, source_family, source_program_completes, source_show, source_space, source_width,
|
||||
type3_encode, type3_font,
|
||||
};
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
use super::lines::{
|
||||
content_streams, dropped_advances, dropped_side_effects, joined, page_dict, reopen_text_object, rewritten_contents, show_side_effects, splice,
|
||||
text_lines,
|
||||
};
|
||||
use super::{
|
||||
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred,
|
||||
parse, rewrite_changed_words, rewrite_styled_block,
|
||||
Dict, Document, EditError, HashMap, HashSet, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks,
|
||||
is_centred, parse, rewrite_changed_words, rewrite_styled_block,
|
||||
};
|
||||
|
||||
/// What replacing a line did.
|
||||
@@ -68,16 +72,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
let reused = font.as_ref().filter(|m| source_encodes(m, &shown));
|
||||
let mut substituted = None;
|
||||
let mut replacement: Vec<Op> = Vec::new();
|
||||
// ' and " also move to the next line; keep that.
|
||||
match ops[first].op.as_slice() {
|
||||
b"'" => replacement.push(Op::new("T*", vec![])),
|
||||
b"\"" => {
|
||||
replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))]));
|
||||
replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))]));
|
||||
replacement.push(Op::new("T*", vec![]));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// ' and " also move to the next line (and " sets the spacing); keep that.
|
||||
replacement.extend(ops.get(first).map(show_side_effects).unwrap_or_default());
|
||||
match reused {
|
||||
Some(metrics) => replacement.extend(source_show(
|
||||
metrics,
|
||||
@@ -134,8 +130,23 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go.
|
||||
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go. Each other
|
||||
// dropped `'` or `"` (a later run, or a copy on the same baseline) leaves its line move and
|
||||
// spacing behind, as the first one does inside the replacement, and a dropped copy its advance
|
||||
// where text after it is placed by that (the line's own runs give way to the replacement's).
|
||||
let mut advances = dropped_advances(doc, &ops, &res, &drops);
|
||||
for i in &target.ops {
|
||||
advances.remove(i);
|
||||
}
|
||||
let new_data = splice(&pieces, &ops, |i| {
|
||||
if i == first {
|
||||
return (std::mem::take(&mut replacement), false);
|
||||
}
|
||||
let keep = !drops.contains(&i);
|
||||
let mut insert = dropped_side_effects(&ops, i, keep, None);
|
||||
insert.extend(advances.remove(&i));
|
||||
(insert, keep)
|
||||
});
|
||||
let contents = rewritten_contents(doc, &streams, new_data)?;
|
||||
let page_ref = p.obj;
|
||||
if substituted.is_some() {
|
||||
@@ -344,6 +355,7 @@ fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width:
|
||||
|
||||
/// The widths of the paragraph's text in the font it will be written in, in user space.
|
||||
struct Advance<'a> {
|
||||
partial_advances: &'a HashMap<char, Option<f64>>,
|
||||
/// The source font, when its glyphs are reused.
|
||||
source: Option<&'a Metrics>,
|
||||
type3: Option<&'a Type3Fallback>,
|
||||
@@ -370,11 +382,24 @@ impl Advance<'_> {
|
||||
(None, Some(fallback)) => {
|
||||
// Each character's count in `s`, from one pass over `s`.
|
||||
let mut counts: HashMap<char, usize> = HashMap::new();
|
||||
let mut source_advance = 0.0;
|
||||
let mut fallback_count = 0usize;
|
||||
for c in s.chars() {
|
||||
let count = counts.entry(c).or_insert(0);
|
||||
*count = count.saturating_add(1);
|
||||
// The selector is emitted with zero spacing, so it cannot widen the line.
|
||||
if is_variation_selector(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(width) = self.partial_advances.get(&c).copied().flatten() {
|
||||
source_advance += width;
|
||||
} else {
|
||||
let count = counts.entry(c).or_insert(0);
|
||||
*count = count.saturating_add(1);
|
||||
fallback_count = fallback_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::<f64>() + extra
|
||||
source_advance
|
||||
+ fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::<f64>()
|
||||
+ fallback_count as f64 * self.char_spacing
|
||||
}
|
||||
(None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra,
|
||||
};
|
||||
@@ -541,6 +566,8 @@ impl Alignment {
|
||||
|
||||
/// Everything the wrapped lines of one paragraph are written with.
|
||||
struct LineWriter<'a> {
|
||||
source_name: &'a str,
|
||||
partial_chars: Option<&'a HashSet<char>>,
|
||||
advance: &'a Advance<'a>,
|
||||
alignment: Alignment,
|
||||
encode: &'a Encoder,
|
||||
@@ -649,7 +676,8 @@ impl LineWriter<'_> {
|
||||
}
|
||||
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
|
||||
let visual = crate::bidi::to_visual(shown);
|
||||
if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) {
|
||||
// Partial reuse must reach show_piece: its missing spaces and glyphs belong to the fallback.
|
||||
if let Some(metrics) = self.source.filter(|m| self.partial_chars.is_none() && m.encode(" ").is_none()) {
|
||||
let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual };
|
||||
ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?);
|
||||
} else {
|
||||
@@ -670,24 +698,30 @@ impl LineWriter<'_> {
|
||||
|
||||
fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<(), EditError> {
|
||||
let n = pdfcraft_content::num;
|
||||
if let Some(metrics) = self.source {
|
||||
let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() };
|
||||
ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?);
|
||||
return Ok(());
|
||||
}
|
||||
for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if run.name != cursor.shown_font {
|
||||
ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)]));
|
||||
cursor.shown_font = run.name;
|
||||
}
|
||||
if let Some(tc) = run.spacing {
|
||||
// A variation selector's empty glyph: shown at spacing zero, then the paragraph's
|
||||
// own spacing back, so it adds no advance.
|
||||
ops.push(Op::new("Tc", vec![n(tc)]));
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
ops.push(Op::new("Tc", vec![n(self.char_spacing)]));
|
||||
for (source, run) in source_fallback_runs(piece, self.partial_chars) {
|
||||
if let Some(metrics) = self.source.filter(|_| self.partial_chars.is_none() || source) {
|
||||
if cursor.shown_font != self.source_name {
|
||||
ops.push(Op::new("Tf", vec![Object::name(self.source_name), n(self.size)]));
|
||||
cursor.shown_font = self.source_name.to_owned();
|
||||
}
|
||||
let indic_piece = if metrics.visual_order { crate::indic::to_visual(run) } else { run.to_owned() };
|
||||
ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?);
|
||||
} else {
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
for run in (self.encode)(run).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if run.name != cursor.shown_font {
|
||||
ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)]));
|
||||
cursor.shown_font = run.name;
|
||||
}
|
||||
if let Some(tc) = run.spacing {
|
||||
// A variation selector's empty glyph: shown at spacing zero, then the paragraph's
|
||||
// own spacing back, so it adds no advance.
|
||||
ops.push(Op::new("Tc", vec![n(tc)]));
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
ops.push(Op::new("Tc", vec![n(self.char_spacing)]));
|
||||
} else {
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -881,6 +915,23 @@ pub(crate) fn rewrite_block_from(
|
||||
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?;
|
||||
let source = source_font(doc, &fonts_res, &font_name, first, &flat, style);
|
||||
let reuse = source.reuse;
|
||||
// Preserve every drawable source glyph; cache missing-glyph lookups as well.
|
||||
let visual_indic = source.metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order;
|
||||
let hebrew = flat.chars().any(crate::bidi::is_hebrew);
|
||||
let source_advances: HashMap<char, Option<f64>> = source
|
||||
.metrics
|
||||
.as_ref()
|
||||
.filter(|_| !reuse && upright && !hebrew && !visual_indic && style.family.is_none() && style.bold.is_none() && needs_type3(&flat))
|
||||
.map(|m| {
|
||||
let mut advances = HashMap::new();
|
||||
for ch in flat.chars() {
|
||||
advances.entry(ch).or_insert_with(|| source_character_advance(m, ch, size, o_state.char_spacing));
|
||||
}
|
||||
advances
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let source_chars: HashSet<char> = source_advances.iter().filter_map(|(&ch, width)| width.map(|_| ch)).collect();
|
||||
let partial_reuse = !source_chars.is_empty();
|
||||
// The chosen style also determines the real Japanese fallback outlines and advances.
|
||||
let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic));
|
||||
let bold = style.bold.unwrap_or(bold);
|
||||
@@ -910,9 +961,15 @@ pub(crate) fn rewrite_block_from(
|
||||
let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width };
|
||||
let movement = drawn_movement(emit.ctm, dx, dy)?;
|
||||
let tm = emit_text_matrix(o, emit)?;
|
||||
let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None };
|
||||
let type3 = if !reuse && needs_type3(&flat) {
|
||||
let missing: String = flat.chars().filter(|ch| !source_chars.contains(ch)).collect();
|
||||
Some(type3_font(doc, &mut fonts_res, &missing, family, bold, max_glyphs)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let source_metrics = source.metrics.as_ref().filter(|_| reuse);
|
||||
let advance = Advance {
|
||||
partial_advances: &source_advances,
|
||||
source: source_metrics,
|
||||
type3: type3.as_ref(),
|
||||
family,
|
||||
@@ -958,10 +1015,12 @@ pub(crate) fn rewrite_block_from(
|
||||
None => type3.is_none(),
|
||||
};
|
||||
let writer = LineWriter {
|
||||
source_name: &font_name,
|
||||
partial_chars: partial_reuse.then_some(&source_chars),
|
||||
advance: &advance,
|
||||
alignment: Alignment { width, k, anchored_right, right_edge, rtl },
|
||||
encode: &font.encode,
|
||||
source: source_metrics,
|
||||
source: source.metrics.as_ref().filter(|_| reuse || partial_reuse),
|
||||
align,
|
||||
aligns: &style.aligns,
|
||||
word_spacing_reaches_spaces,
|
||||
@@ -995,16 +1054,18 @@ pub(crate) fn rewrite_block_from(
|
||||
};
|
||||
let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?;
|
||||
let on_top = crate::lift_on_top(over, &mut block_ops);
|
||||
let n = pdfcraft_content::num;
|
||||
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
|
||||
if split {
|
||||
block_ops.insert(0, Op::new("ET", vec![]));
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect()));
|
||||
block_ops.extend(reopen_text_object(emit.tlm, emit.tm, after.font.as_ref().map(|(_, size)| *size), after.scale));
|
||||
}
|
||||
let mut advances = dropped_advances(doc, &ops, &res, &dropped);
|
||||
let new_data = splice(&pieces, &ops, |i| {
|
||||
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
|
||||
(insert, !dropped.contains(&i))
|
||||
let mut insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
|
||||
let keep = !dropped.contains(&i);
|
||||
insert.extend(dropped_side_effects(&ops, i, keep, split.then_some(start)));
|
||||
insert.extend(advances.remove(&i));
|
||||
(insert, keep)
|
||||
});
|
||||
let contents = rewritten_contents(doc, &streams, new_data)?;
|
||||
let new_font = !reuse;
|
||||
|
||||
@@ -150,8 +150,8 @@ fn key(line: &TextLine) -> (&str, [i64; 4]) {
|
||||
}
|
||||
|
||||
/// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error
|
||||
/// when it moved or lost any other text on the page: text positioned after the end of the text
|
||||
/// before it, say, depends on the words that went.
|
||||
/// when it moved or lost any other text on the page: text positioned after the end of the words
|
||||
/// that went, say, when their advance couldn't be left behind (see `dropped_advances`).
|
||||
fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result<Option<f64>, EditError> {
|
||||
let mut unmatched: HashMap<_, usize> = HashMap::new();
|
||||
for line in before {
|
||||
|
||||
@@ -116,8 +116,11 @@ struct LegacyFace {
|
||||
space: f64,
|
||||
}
|
||||
|
||||
/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets
|
||||
/// (each at most 16 MiB): a page may name many of them.
|
||||
/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most
|
||||
/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their
|
||||
/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its
|
||||
/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among
|
||||
/// the page's fonts aren't charged to it.
|
||||
const MAX_THAI_PROGRAMS: usize = 64 << 20;
|
||||
|
||||
struct PreparedCharacters {
|
||||
@@ -804,9 +807,8 @@ fn wrap_styled_rows(
|
||||
|
||||
fn apply_range_styles<'a>(
|
||||
first: &TextLine,
|
||||
block: &TextBlock,
|
||||
lines: &[TextLine],
|
||||
draft: &str,
|
||||
sources: Vec<(std::ops::Range<usize>, usize)>,
|
||||
ranges: &[&'a RangeStyle],
|
||||
style: &BlockStyle,
|
||||
) -> StyledLines<'a> {
|
||||
@@ -814,7 +816,7 @@ fn apply_range_styles<'a>(
|
||||
let mut styled_spans = Vec::new();
|
||||
let mut underlines = HashMap::new();
|
||||
let mut overrides = HashMap::new();
|
||||
for (span, source) in block.edited_spans(draft) {
|
||||
for (span, source) in sources {
|
||||
let mut edges = vec![span.start, span.end];
|
||||
for range in ranges {
|
||||
for edge in [range.range.start, range.range.end] {
|
||||
@@ -900,6 +902,9 @@ fn legacy_thai_font(
|
||||
}
|
||||
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
|
||||
let mut candidates = vec![source.font.clone()];
|
||||
// Every name on the page is read here for its face name alone, aliases of one font and other
|
||||
// faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would
|
||||
// pay again and other faces would spend what the face's subsets need.
|
||||
candidates.extend(fonts.iter().filter_map(|(name, font)| {
|
||||
let font = doc.dict(font)?;
|
||||
(pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned())
|
||||
@@ -914,9 +919,15 @@ fn legacy_thai_font(
|
||||
let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) {
|
||||
Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias },
|
||||
None => {
|
||||
let metrics = Metrics::from_dict(doc, &font);
|
||||
let space = source_space(doc, fonts, &metrics);
|
||||
let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new);
|
||||
// Read within the edit's allowance; past it the subset goes unread, and the edit is
|
||||
// refused below.
|
||||
let metrics = Metrics::from_dict_within(doc, &font, budget);
|
||||
let (space, glyphs) = if metrics.past_allowance {
|
||||
(0.0, None)
|
||||
} else {
|
||||
let space = source_space(doc, fonts, &metrics);
|
||||
(space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new))
|
||||
};
|
||||
let style = FontStyle::of(doc, &font, &metrics);
|
||||
LegacyFace { glyphs, metrics, style, object, space }
|
||||
}
|
||||
@@ -924,9 +935,11 @@ fn legacy_thai_font(
|
||||
cache.insert(name, face);
|
||||
}
|
||||
// A subset left unread would count as unusable, silently changing which subset draws the text.
|
||||
if budget.remaining() == 0 {
|
||||
// A read refused for want of the allowance is what refuses the edit: reads that spent it to the
|
||||
// byte refused nothing.
|
||||
if budget.refused() > 0 {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text",
|
||||
"the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text",
|
||||
source.base_font.rsplit('+').next().unwrap_or(&source.base_font),
|
||||
MAX_THAI_PROGRAMS >> 20
|
||||
)));
|
||||
@@ -1532,7 +1545,8 @@ fn push_row_break_marker(block_ops: &mut Vec<Op>, row_soft: &[bool], row_gap: &[
|
||||
/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P`
|
||||
/// marker opened at its start and closed at its `next` line or, when nothing follows, after the
|
||||
/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or
|
||||
/// beyond `ops.len()`, since `splice`'s callback never visits that index).
|
||||
/// beyond `ops.len()`, since `splice`'s callback never visits that index). A dropped operator
|
||||
/// `i` leaves `left_behind(i, keep)` in its place: its line move, spacing and advance.
|
||||
fn splice_with_marker(
|
||||
pieces: &Pieces,
|
||||
ops: &[Op],
|
||||
@@ -1540,6 +1554,7 @@ fn splice_with_marker(
|
||||
at: usize,
|
||||
mut block_ops: Vec<Op>,
|
||||
drop: &std::collections::HashSet<usize>,
|
||||
mut left_behind: impl FnMut(usize, bool) -> Vec<Op>,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let data = splice(pieces, ops, |i| {
|
||||
let mut insert = Vec::new();
|
||||
@@ -1557,7 +1572,9 @@ fn splice_with_marker(
|
||||
if i == at {
|
||||
insert.append(&mut block_ops);
|
||||
}
|
||||
(insert, !drop.contains(&i))
|
||||
let keep = !drop.contains(&i);
|
||||
insert.extend(left_behind(i, keep));
|
||||
(insert, keep)
|
||||
});
|
||||
if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) {
|
||||
return data;
|
||||
@@ -1573,6 +1590,25 @@ fn splice_with_marker(
|
||||
data
|
||||
}
|
||||
|
||||
/// The source run each part of the draft is styled from: the ones given, which must cover the draft
|
||||
/// in order, on character boundaries, from the block's runs, or else the edit's own.
|
||||
fn draft_sources(block: &TextBlock, draft: &str, style: &BlockStyle) -> Result<Vec<(std::ops::Range<usize>, usize)>, EditError> {
|
||||
if style.sources.is_empty() {
|
||||
return Ok(block.edited_spans(draft));
|
||||
}
|
||||
let mut at = 0;
|
||||
for (range, source) in &style.sources {
|
||||
if range.start != at || range.start >= range.end || draft.get(range.clone()).is_none() || !block.lines.contains(source) {
|
||||
return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into()));
|
||||
}
|
||||
at = range.end;
|
||||
}
|
||||
if at != draft.len() {
|
||||
return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into()));
|
||||
}
|
||||
Ok(style.sources.clone())
|
||||
}
|
||||
|
||||
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
|
||||
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
|
||||
pub(crate) fn rewrite_styled_block(
|
||||
@@ -1591,7 +1627,8 @@ pub(crate) fn rewrite_styled_block(
|
||||
}
|
||||
let ranges = validated_ranges(draft, style)?;
|
||||
let source_count = lines.len();
|
||||
let styled = apply_range_styles(first, block, lines, draft, &ranges, style);
|
||||
let sources = draft_sources(block, draft, style)?;
|
||||
let styled = apply_range_styles(first, lines, sources, &ranges, style);
|
||||
let lines = styled.lines.as_slice();
|
||||
let styled_spans = styled.spans;
|
||||
let underlines = styled.underlines;
|
||||
@@ -1693,7 +1730,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
|
||||
// glyph widths stay exactly as they were instead of being rebuilt from the text state. A row
|
||||
// aligned on its own is reached; the rows around it are not.
|
||||
let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
let plain = BlockStyle { ranges: Vec::new(), sources: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
let mut kept = if plain {
|
||||
untouched_rows(block, draft, &row_ranges)
|
||||
.into_iter()
|
||||
@@ -1882,14 +1919,21 @@ pub(crate) fn rewrite_styled_block(
|
||||
block_ops.extend(after.ops());
|
||||
if split {
|
||||
block_ops.insert(0, Op::new("ET", vec![]));
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
|
||||
block_ops.extend(crate::text::reopen_text_object(o.tlm, o.tm, after.font.as_ref().map(|(_, size)| *size), after.scale));
|
||||
}
|
||||
// A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is
|
||||
// safe. The close is not part of `block_ops`: that vector can end inside a text object
|
||||
// when the rewrite splits one, and a kept row's operators come later in the stream.
|
||||
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
|
||||
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop);
|
||||
// A dropped `'` or `"` leaves its line move and spacing, and a dropped show its advance.
|
||||
let mut advances = crate::text::dropped_advances(doc, &ops, &res, &drop);
|
||||
let split_at = split.then_some(start);
|
||||
let left_behind = |i: usize, keep: bool| {
|
||||
let mut out = crate::text::dropped_side_effects(&ops, i, keep, split_at);
|
||||
out.extend(advances.remove(&i));
|
||||
out
|
||||
};
|
||||
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop, left_behind);
|
||||
let contents = rewritten_contents(doc, &streams, data)?;
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Convert colours: `/DeviceCMYK cs` starts at black (0 0 0 1, ISO 32000-1 8.6.5.5), so the
|
||||
//! converted operator is black too, not the white that four zeros would be.
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}`
|
||||
/// entry is written as a stream holding the text after the colon, with its `/Length`.
|
||||
fn pdf(objs: &[&str]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
let body = match o.split_once(">>stream:") {
|
||||
Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()),
|
||||
None => (*o).to_string(),
|
||||
};
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn flat(b: &[u8]) -> String {
|
||||
String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn stream_text(doc: &Document, r: ObjRef) -> String {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => flat(&s.decoded().unwrap()),
|
||||
_ => panic!("not a stream"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's `/Contents` references.
|
||||
fn contents(doc: &Document, page: usize) -> Vec<ObjRef> {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(page);
|
||||
match &*doc.resolve(p.dict.get(b"Contents").unwrap()) {
|
||||
Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(),
|
||||
_ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option<Stream> {
|
||||
None
|
||||
}
|
||||
|
||||
fn page_with(content: &str) -> Document {
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>",
|
||||
&format!("<< >>stream:{content}"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_cmyk_starts_at_black() {
|
||||
let mut doc = page_with("/DeviceCMYK cs 0 0 10 10 re f /DeviceCMYK CS 0 0 m 10 10 l S");
|
||||
let s = ConvertColours { preserve_black: false, ..ConvertColours::default() };
|
||||
let done = convert_colours(&mut doc, &[0], &s, &mut no_images).unwrap();
|
||||
assert_eq!(done.colours, 2);
|
||||
let t = stream_text(&doc, contents(&doc, 0)[0]);
|
||||
assert!(t.contains("0 g 0 0 10 10 re f") && t.contains("0 G 0 0 m 10 10 l S"), "black, not white: {t}");
|
||||
}
|
||||
Loaded 100 of 522 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user