mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 01:41:18 +00:00
Merge upstream/main into feat/recent-reveal-5452
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9f96a6f222
290 files changed
+24594
-1708
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
+52
-17
@@ -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=1ee01a4c58729d781638b8afbd8982726dbdefcb#1ee01a4c58729d781638b8afbd8982726dbdefcb"
|
||||
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=3c978dfcf3d460577fabd87aeea7a84ffdcfb396)",
|
||||
"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"
|
||||
@@ -4416,6 +4420,7 @@ dependencies = [
|
||||
"sha2 0.11.1",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4564,6 +4569,7 @@ dependencies = [
|
||||
"pdfcraft-fonts",
|
||||
"png 0.18.1",
|
||||
"rten",
|
||||
"rten-imageproc",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tempfile",
|
||||
@@ -4618,6 +4624,7 @@ version = "0.18.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
"log",
|
||||
"rustix 1.1.5",
|
||||
"zbus",
|
||||
]
|
||||
@@ -4656,11 +4663,14 @@ name = "pdfcraft-print"
|
||||
version = "0.18.1"
|
||||
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]]
|
||||
@@ -4747,6 +4757,7 @@ dependencies = [
|
||||
"sha2 0.11.1",
|
||||
"sha3",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4768,6 +4779,7 @@ dependencies = [
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"edit-session",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4808,6 +4820,7 @@ dependencies = [
|
||||
"web-time",
|
||||
"winsafe",
|
||||
"xattr",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8310,7 +8323,29 @@ 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=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396"
|
||||
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 = "winprint"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"fmt-derive",
|
||||
@@ -8845,14 +8880,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"
|
||||
+19
-16
@@ -12,6 +12,8 @@ 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,7 +26,7 @@ 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 = "1ee01a4c58729d781638b8afbd8982726dbdefcb" }
|
||||
# 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
+67
@@ -1,5 +1,8 @@
|
||||
# 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)
|
||||
@@ -29,6 +32,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)
|
||||
|
||||
@@ -37,10 +41,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)
|
||||
@@ -239,6 +252,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.
|
||||
@@ -293,6 +326,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.
|
||||
@@ -301,7 +335,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.
|
||||
@@ -332,7 +373,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.
|
||||
@@ -449,6 +493,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **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.
|
||||
@@ -593,6 +639,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.
|
||||
@@ -823,14 +872,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. |
|
||||
@@ -838,8 +901,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. |
|
||||
|
||||
@@ -434,7 +434,10 @@ 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 _ =
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -535,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,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,
|
||||
@@ -340,6 +340,9 @@ 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();
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::create::{
|
||||
};
|
||||
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
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, is_fill_sign, page_ref, set_annots};
|
||||
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,
|
||||
@@ -117,7 +117,10 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
|
||||
/// go together from either ([`own_deletion`]).
|
||||
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
|
||||
let index = deleted_index(doc, page, index)?;
|
||||
lift_annotation(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
|
||||
@@ -133,6 +136,10 @@ fn deleted_index(doc: &Document, page: usize, index: usize) -> Result<usize, Ann
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
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)?;
|
||||
}
|
||||
@@ -425,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)
|
||||
}
|
||||
|
||||
@@ -545,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -2544,6 +2544,28 @@ fn non_markup_annotations_are_not_comments() {
|
||||
for s in ["Text", "FreeText", "Highlight", "Ink", "Stamp", "FileAttachment", "Redact", "Sound"] {
|
||||
assert!(is_comment_subtype(s), "{s}");
|
||||
}
|
||||
// #3916: comment edits must refuse non-comment annotations (form widgets, links, pop-ups)
|
||||
// when a stale `/Annots` index points at one.
|
||||
for subtype in ["Widget", "Link", "Popup", "Watermark"] {
|
||||
let mut doc = fixture();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
d.set(b"Rect".to_vec(), num_array(&[10.0, 10.0, 50.0, 30.0]));
|
||||
d.set(b"Contents".to_vec(), PdfString::text("keep me"));
|
||||
let w = doc.add(Object::Dict(d));
|
||||
let p = page_refs(&doc).unwrap()[0];
|
||||
let mut a = annots(&doc, p);
|
||||
a.push(Object::Ref(w));
|
||||
let idx = a.len() - 1;
|
||||
set_annots(&mut doc, p, a).unwrap();
|
||||
|
||||
assert_eq!(delete_annotation(&mut doc, 0, idx), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}");
|
||||
assert_eq!(set_contents(&mut doc, 0, idx, "overwritten", &meta("m")), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}");
|
||||
assert_eq!(move_annotation(&mut doc, 0, idx, 5.0, 5.0, &meta("m")), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}");
|
||||
assert_eq!(props(&doc, 0, idx), None, "{subtype}");
|
||||
assert_eq!(text(&list(&doc, 0)[idx], b"Contents"), "keep me", "{subtype} contents must be untouched");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing (a drawn Fill & Sign signature is one) resizes by scaling its strokes into the new
|
||||
@@ -5507,3 +5529,93 @@ fn summaries_list_only_usable_quads() {
|
||||
let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap();
|
||||
assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]);
|
||||
}
|
||||
|
||||
/// A page as iOS Markup saves a signature: its private copy on the page and on the stamp, which
|
||||
/// also carries a normal appearance. A plain note follows the stamp, at index 1.
|
||||
fn apple_markup_page() -> Document {
|
||||
document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] /AAPL:PPK 5 0 R /AAPL:PPKHash 5 0 R >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AP << /N 6 0 R >> /AAPL:AKExtras << /PPKType (draw) >> >>",
|
||||
b"<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream",
|
||||
b"<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 22 >>
|
||||
stream
|
||||
0 0 1 RG 1 1 m 9 9 l S
|
||||
endstream",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [400 400 420 420] /Contents (note) >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// Apple's copy is gone from the page and from every annotation left on it, and the drop is
|
||||
/// in the repair log (fidelity: never silent).
|
||||
fn assert_apple_copy_dropped(doc: &Document) {
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("Apple Markup")), "{:?}", doc.repair_log());
|
||||
let doc = reopen(doc);
|
||||
let page = doc.get(page_refs(&doc).unwrap()[0]);
|
||||
let page = page.as_dict().unwrap();
|
||||
assert!(!page.contains(b"AAPL:PPK") && !page.contains(b"AAPL:PPKHash"));
|
||||
let all = list(&doc, 0);
|
||||
assert_eq!(all[0].name(b"Subtype"), Some(&b"Stamp"[..]));
|
||||
assert!(all.iter().all(|d| !d.contains(b"AAPL:AKExtras")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_from_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
delete_annotation(&mut doc, 0, 1).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replying_on_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
add_reply(&mut doc, 0, 1, "Agreed", "Bob", &meta("r")).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_on_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
set_marked(&mut doc, 0, 1, true, "Bob", &meta("m")).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_to_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
add_annotation(&mut doc, &new(0, Shape::Note { at: [300.0, 300.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
// The Markup stamp itself stays, with its flags and its appearance, byte for byte.
|
||||
let doc = reopen(&doc);
|
||||
let all = list(&doc, 0);
|
||||
assert_eq!(all[0].get(b"F").and_then(Object::as_int), Some(644));
|
||||
assert_eq!(ap_content(&doc, &all[0]), "0 0 1 RG 1 1 m 9 9 l S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_edit_on_an_apple_markup_page_drops_nothing() {
|
||||
// An inline, locked Markup stamp: restyling it is refused, so Apple's copy must stay put
|
||||
// (promoting the inline dictionary to an object is not a change to the markup).
|
||||
let mut doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AAPL:AKExtras << /PPKType (draw) >> >>] /AAPL:PPK 4 0 R /AAPL:PPKHash 4 0 R >>",
|
||||
b"<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream",
|
||||
]);
|
||||
assert_eq!(
|
||||
set_style(&mut doc, 0, 0, StyleChange { color: Some([1.0, 0.0, 0.0]), opacity: None, width: None, endings: None }, &meta("")),
|
||||
Err(AnnotError::Invalid("the comment is locked".into()))
|
||||
);
|
||||
let page = doc.get(page_refs(&doc).unwrap()[0]);
|
||||
let page = page.as_dict().unwrap();
|
||||
assert!(page.contains(b"AAPL:PPK") && page.contains(b"AAPL:PPKHash"));
|
||||
assert!(list(&doc, 0)[0].contains(b"AAPL:AKExtras"));
|
||||
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -14,6 +14,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -730,6 +731,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)?,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -222,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,7 +509,9 @@ 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",
|
||||
|
||||
@@ -23,47 +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), and tells
|
||||
/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the
|
||||
/// caller chose in `options` stands.
|
||||
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: usize, sheets: &[print::Sheet]) -> 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
|
||||
@@ -81,10 +138,12 @@ 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: {
|
||||
let mut options: Vec<(String, String)> = options.into_iter().chain(print::media_option(paper)).collect();
|
||||
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
|
||||
},
|
||||
@@ -159,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> {
|
||||
@@ -218,6 +292,7 @@ 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),
|
||||
@@ -226,7 +301,15 @@ impl Automation {
|
||||
},
|
||||
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"));
|
||||
}
|
||||
@@ -241,7 +324,18 @@ impl Automation {
|
||||
};
|
||||
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:?}")))?;
|
||||
let (paper_index, paper) = paper_named(a.opt_str("paper")?)?;
|
||||
// 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) {
|
||||
@@ -253,20 +347,32 @@ 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();
|
||||
// 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();
|
||||
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")?) {
|
||||
match (a.opt_str("path")?, printer) {
|
||||
(Some(p), None) => {
|
||||
let path = self.resolve(p, true)?;
|
||||
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, &laid_out)?;
|
||||
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
|
||||
let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?;
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
|
||||
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\")")),
|
||||
@@ -275,21 +381,59 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
/// "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
|
||||
@@ -312,7 +456,7 @@ mod tests {
|
||||
#[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, 0, s).unwrap().options;
|
||||
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())] };
|
||||
@@ -328,8 +472,8 @@ mod tests {
|
||||
#[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.
|
||||
@@ -338,14 +482,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -183,7 +183,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
)),
|
||||
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.")
|
||||
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!({}), &[])),
|
||||
@@ -212,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." },
|
||||
@@ -276,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(
|
||||
@@ -743,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. 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!({
|
||||
@@ -764,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." },
|
||||
}),
|
||||
@@ -854,7 +874,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page 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"],
|
||||
@@ -1395,7 +1415,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
|
||||
.cmd("form.merge_data")
|
||||
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the 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 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, attachment) 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.")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
|
||||
@@ -9,7 +9,16 @@ use serde_json::{Value, json};
|
||||
|
||||
/// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", …
|
||||
fn fixture(n: usize) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||||
fixture_with(n, "")
|
||||
}
|
||||
|
||||
/// Named destinations for [`fixture_with`]: `chapter` (page 2) in the catalog's /Dests and
|
||||
/// `Load Flow` (page 3) in the /Names /Dests tree.
|
||||
const NAMED_DESTS: &str = "/Dests << /chapter [6 0 R /Fit] >> /Names << /Dests << /Names [(Load Flow) [8 0 R /XYZ 0 300 0]] >> >>";
|
||||
|
||||
/// [`fixture`] with `catalog` added to the catalog dictionary. Page `i` (0-based) is object `4 + 2i`.
|
||||
fn fixture_with(n: usize, catalog: &str) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
|
||||
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
|
||||
@@ -4388,6 +4397,37 @@ fn accessibility_review_sidecar_tracks_human_decisions_for_exact_pdf_revision()
|
||||
}
|
||||
}
|
||||
|
||||
/// Find and replace through text_replace: every match (one undo step), the first only, the find
|
||||
/// options, and counts that add up to what text_find reports.
|
||||
#[test]
|
||||
fn replacing_text_through_tools() {
|
||||
let dir = workdir("replace-text");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page" }))["count"].as_u64();
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet" }));
|
||||
assert_eq!((r["replaced"].as_u64(), r["skipped"].as_u64(), r["changed"].as_bool()), (Some(3), Some(0), Some(true)));
|
||||
assert_eq!(found, r["replaced"].as_u64(), "what text_find found is what was replaced");
|
||||
assert_eq!(r["pages"], json!([1, 2, 3]));
|
||||
assert_eq!(page_text(&mut a, doc), ["Sheet 1", "Sheet 2", "Sheet 3"]);
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Replace all");
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"], "one undo step");
|
||||
// The first match only, on the pages asked for.
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet", "all": false, "pages": [2, 3] }));
|
||||
assert_eq!((r["replaced"].as_u64(), r["pages"].clone()), (Some(1), json!([2])));
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Sheet 2", "Page 3"]);
|
||||
// Case-sensitive and whole words follow the find bar's options; nothing found changes nothing.
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "x", "case_sensitive": true }));
|
||||
assert_eq!((r["replaced"].as_u64(), r["changed"].as_bool()), (Some(0), Some(false)));
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Pag", "replace": "x", "whole_words": true }));
|
||||
assert_eq!(r["replaced"].as_u64(), Some(0));
|
||||
// An empty replacement deletes the match.
|
||||
ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Page ", "replace": "" }));
|
||||
assert_eq!(page_text(&mut a, doc), ["1", "Sheet 2", "3"]);
|
||||
assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x" })), Err(ToolError::InvalidArgs(_))), "replace is required");
|
||||
assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x", "replace": "y", "pages": [9] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_existing_text_through_tools() {
|
||||
let dir = workdir("edit-text");
|
||||
@@ -4592,6 +4632,51 @@ fn a_line_split_across_content_streams_is_listed_and_edited() {
|
||||
assert_eq!(page_text(&mut a, doc)[0].split_whitespace().collect::<Vec<_>>(), ["Target", "Later"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_paragraph_keeps_source_fonts_through_tools_and_reopen() {
|
||||
if pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
eprintln!("skipping mixed-script fallback: build with CRAFT_FONTS_DIR");
|
||||
return;
|
||||
}
|
||||
let dir = workdir("mixed-script-paragraph");
|
||||
let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
cos.update_dict(pdfcraft_cos::ObjRef::new(2, 0), |d| {
|
||||
d.set(b"MediaBox".to_vec(), pdfcraft_cos::Object::Array([0, 0, 612, 792].map(pdfcraft_cos::Object::Int).to_vec()));
|
||||
})
|
||||
.unwrap();
|
||||
let body =
|
||||
b"BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET";
|
||||
cos.set(pdfcraft_cos::ObjRef::new(5, 0), pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), body)));
|
||||
std::fs::write(dir.join("mixed.pdf"), pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"mixed.pdf"}))["doc"].as_u64().unwrap();
|
||||
let draft = "The quick brown fox jumps over the lazy dog шалом and keeps running through the long field.";
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":draft}));
|
||||
let check = |a: &mut Automation, doc, expected: &str| {
|
||||
let listed = ok(a, "text_lines", json!({"doc":doc,"page":1}));
|
||||
let lines = listed["lines"].as_array().unwrap();
|
||||
assert!(lines.iter().any(|l| l["font"] == "Helvetica" && l["text"].as_str().unwrap().contains("The quick brown fox")), "{listed}");
|
||||
for line in lines.iter().filter(|l| l["text"].as_str().unwrap().chars().any(|c| c.is_ascii_alphabetic())) {
|
||||
assert_eq!(line["font"], "Helvetica", "{line}");
|
||||
}
|
||||
let text = page_text(a, doc).join(" ");
|
||||
assert_eq!(text.split_whitespace().collect::<Vec<_>>(), expected.split_whitespace().collect::<Vec<_>>());
|
||||
};
|
||||
check(&mut a, doc, draft);
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert!(!page_text(&mut a, doc).join(" ").contains("шалом"));
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
check(&mut a, doc, draft);
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
|
||||
ok(&mut a, "doc_close", json!({"doc":doc}));
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
check(&mut a, doc, draft);
|
||||
let extended = format!("{draft} More Latin.");
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":extended}));
|
||||
check(&mut a, doc, &extended);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_bold_without_font_keeps_the_source_family() {
|
||||
let dir = workdir("paragraph-bold");
|
||||
@@ -6200,10 +6285,32 @@ fn printing_through_tools() {
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": printed }))["document"]["pages"].as_u64(), Some(n.div_ceil(4)));
|
||||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "layout": "poster", "scale": 400, "path": "poster.pdf" }));
|
||||
assert!(r["sheets"].as_u64().unwrap() > 1);
|
||||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "booklet", "creep": 1.5, "path": "booklet.pdf" }));
|
||||
assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4) * 2), "creep keeps the booklet's sheets");
|
||||
assert!(a.call("doc_print", &json!({ "doc": doc, "layout": "booklet", "creep": -1, "path": "x.pdf" })).is_err(), "negative creep");
|
||||
assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_papers_through_tools() {
|
||||
let dir = workdir("printer-papers");
|
||||
let mut a = auto(&dir);
|
||||
let none = ok(&mut a, "printer_papers", json!({ "printer": "No_Such_Queue_PdfCraft" }));
|
||||
assert_eq!((none["count"].as_u64(), none["papers"].as_array().map(Vec::len)), (Some(0), Some(0)));
|
||||
assert!(none["default"].is_null());
|
||||
assert!(matches!(a.call("printer_papers", &json!({})), Err(ToolError::InvalidArgs(_))));
|
||||
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": "300x500", "path": "custom-paper.pdf" }));
|
||||
assert_eq!(r["sheets"].as_u64(), Some(1));
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "custom-paper.pdf" }))["doc"].as_u64().unwrap();
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": opened }));
|
||||
let page = &info["pages"][0];
|
||||
assert!((page["width"].as_f64().unwrap() - 300.0).abs() <= 1.0);
|
||||
assert!((page["height"].as_f64().unwrap() - 500.0).abs() <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_content_through_tools() {
|
||||
let dir = workdir("content");
|
||||
@@ -7350,6 +7457,43 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_open_reads_open_parameters_after_the_file_name() {
|
||||
// #3425: help systems start a PDF reader with `manual.pdf#nameddest=…` or `#page=…`.
|
||||
let (_base, root) = sandbox("open-parameters");
|
||||
std::fs::write(root.join("manual.pdf"), fixture_with(3, NAMED_DESTS)).unwrap();
|
||||
std::fs::write(root.join("odd#page=2.pdf"), fixture(1)).unwrap();
|
||||
let mut a = auto(&root);
|
||||
for (path, kind, page) in [("manual.pdf#page=3", "page", 3), ("manual.pdf#nameddest=Load Flow", "named", 3), ("manual.pdf#chapter", "named", 2)] {
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": path }));
|
||||
assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("manual.pdf"), Some(3)), "{path}");
|
||||
assert_eq!(opened["destination_applied"], true, "{path}: {opened}");
|
||||
assert_eq!((opened["destination"]["kind"].as_str(), opened["destination"]["page"].as_u64()), (Some(kind), Some(page)), "{path}");
|
||||
}
|
||||
for (path, warning) in
|
||||
[("manual.pdf#nameddest=Nowhere", "named destination"), ("manual.pdf#page=9", "out of range"), ("manual.pdf#page=99999", "out of range")]
|
||||
{
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": path }));
|
||||
assert_eq!(opened["destination_applied"], false, "{path}");
|
||||
assert!(opened["warning"].as_str().is_some_and(|w| w.contains(warning)), "{path}: {opened}");
|
||||
}
|
||||
// A parameter that can't be applied isn't a failure: the file opens at its default view.
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "manual.pdf#page=0" }));
|
||||
assert_eq!(opened["pages"].as_u64(), Some(3));
|
||||
assert_eq!((opened["destination_applied"].as_bool(), opened["destination"]["kind"].as_str()), (Some(true), Some("default")), "{opened}");
|
||||
assert!(opened["warning"].is_null(), "{opened}");
|
||||
// A file whose own name has a `#` opens as itself, and a plain path reports no destination.
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "odd#page=2.pdf" }));
|
||||
assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("odd#page=2.pdf"), Some(1)));
|
||||
assert!(opened.get("destination").is_none(), "{opened}");
|
||||
// The file before the `#` must exist, inside --root.
|
||||
let before = a.session().docs().len();
|
||||
for path in ["missing.pdf#page=2", "../outside/secret.pdf#page=1"] {
|
||||
err(&mut a, "doc_open", json!({ "path": path }));
|
||||
assert_eq!(a.session().docs().len(), before, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_gotor_link_open_applies_destinations_and_honors_root() {
|
||||
let root = local_gotor_fixtures();
|
||||
@@ -7357,6 +7501,7 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
|
||||
assert_relative_gotor_destination(&mut a);
|
||||
assert_absolute_gotor_policy(&mut a);
|
||||
assert_unapplied_gotor_destinations(&mut a);
|
||||
assert_named_gotor_destination(&mut a);
|
||||
assert_encrypted_gotor_passwords(&mut a);
|
||||
assert_unsupported_gotor_targets(&mut a);
|
||||
assert_gotor_root_boundaries(&mut a);
|
||||
@@ -7371,6 +7516,8 @@ fn local_gotor_fixtures() -> PathBuf {
|
||||
std::fs::write(root.join("sub/source.pdf"), remote_link_pdf("../target.pdf", Some("[2 /FitH 180]"))).unwrap();
|
||||
std::fs::write(root.join("sub/escape.pdf"), remote_link_pdf("../../outside/secret.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("named.pdf"), remote_link_pdf("target.pdf", Some("(chapter)"))).unwrap();
|
||||
std::fs::write(root.join("named-target.pdf"), fixture_with(3, NAMED_DESTS)).unwrap();
|
||||
std::fs::write(root.join("named-found.pdf"), remote_link_pdf("named-target.pdf", Some("(chapter)"))).unwrap();
|
||||
std::fs::write(root.join("range.pdf"), remote_link_pdf("target.pdf", Some("[99 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("url.pdf"), remote_link_pdf("https://example.org/target.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("missing-source.pdf"), remote_link_pdf("does-not-exist.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
@@ -7419,6 +7566,14 @@ fn assert_unapplied_gotor_destinations(a: &mut Automation) {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_named_gotor_destination(a: &mut Automation) {
|
||||
// A name the target defines is looked up there, as the app does.
|
||||
let source = ok(a, "doc_open", json!({ "path": "named-found.pdf" }))["doc"].as_u64().unwrap();
|
||||
let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
assert_eq!(opened["destination_applied"], true, "{opened}");
|
||||
assert_eq!(opened["destination"], json!({ "kind": "named", "name": "chapter", "page": 2, "view": { "kind": "fit" } }));
|
||||
}
|
||||
|
||||
fn assert_encrypted_gotor_passwords(a: &mut Automation) {
|
||||
let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
@@ -9325,9 +9480,19 @@ fn javascript_through_tools() {
|
||||
])
|
||||
);
|
||||
assert_eq!(r["requests"][0]["page"], 2);
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "JSON.stringify(this.getPageBox())" }));
|
||||
assert_eq!(r["result"], "[0,300,200,0]");
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
|
||||
assert_eq!(total["value"], "3750");
|
||||
let calculated = ok(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({ "doc": doc, "script": "getField('Price').value = 3; getField('Qty').value = 2; calculateNow(); console.println(getField('Total').value);" }),
|
||||
);
|
||||
assert!(calculated["console"].as_array().unwrap().iter().any(|line| line == "6"), "{calculated}");
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert_eq!(f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap()["value"], "6");
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" }));
|
||||
assert!(r["error"].as_str().unwrap().contains("nope"));
|
||||
assert_eq!(r["document_errors"], json!([]));
|
||||
@@ -12073,6 +12238,32 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres,
|
||||
/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn doc_print_takes_a_paper_size_of_its_own() {
|
||||
let dir = workdir("print-paper-size");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (paper, want) in
|
||||
[("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))]
|
||||
{
|
||||
let file = format!("{}.pdf", want.0);
|
||||
ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file }));
|
||||
let bytes = std::fs::read(dir.join(&file)).unwrap();
|
||||
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let page = pdfcraft_model::pages(&out)[0].display_size(&out);
|
||||
assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}");
|
||||
}
|
||||
for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] {
|
||||
match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) {
|
||||
Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"),
|
||||
other => panic!("{paper}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside
|
||||
/// 72–600 is refused.
|
||||
#[test]
|
||||
@@ -12111,6 +12302,53 @@ fn a_root_less_write_output_is_atomic_too() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `doc_print` prints a selected area (Acrobat's "print selected area"): one page's rectangle,
|
||||
/// in points from the top-left, at actual size on its own sheet; several pages, an empty area or
|
||||
/// a layout that isn't a size choice are refused.
|
||||
#[test]
|
||||
fn doc_print_a_selected_area() {
|
||||
let dir = workdir("print-area");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"doc_print",
|
||||
json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "actual", "paper": "Letter", "path": "area.pdf" }),
|
||||
);
|
||||
assert_eq!((r["sheets"].as_u64(), r["pages"].as_u64()), (Some(1), Some(1)));
|
||||
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("area.pdf")).unwrap())).unwrap();
|
||||
let pages = pdfcraft_model::pages(&out);
|
||||
assert_eq!(pages.len(), 1);
|
||||
// The sheet is the paper (Letter, turned to suit the wide area); the page inside it is cut to the area: a clip to the
|
||||
// area's rectangle, then the source page drawn through it, and nothing else of the page.
|
||||
let media: Vec<f64> =
|
||||
out.resolve(pages[0].dict.get(b"MediaBox").unwrap()).as_array().unwrap().iter().map(|v| out.resolve(v).as_f64().unwrap()).collect();
|
||||
assert_eq!(media, [0.0, 0.0, 792.0, 612.0], "a landscape area takes a landscape Letter sheet");
|
||||
let contents = out.resolve(pages[0].dict.get(b"Contents").unwrap());
|
||||
let pdfcraft_cos::Object::Stream(stream) = &*contents else { panic!("sheet content") };
|
||||
let content = String::from_utf8(stream.decoded().unwrap()).unwrap();
|
||||
let tokens: Vec<&str> = content.split_whitespace().collect();
|
||||
let at = tokens.iter().position(|t| *t == "re").expect("the sheet clips to a rectangle");
|
||||
let rect: Vec<f64> = tokens[at - 4..at].iter().map(|t| t.parse().unwrap()).collect();
|
||||
assert_eq!(&tokens[at + 1..at + 3], ["W", "n"], "the rectangle is the clip: {content}");
|
||||
assert_eq!(rect, [36.0, 164.0, 164.0, 100.0], "the clip is the area (36..200 wide, 36..136 from the top, cut to the 200 x 300 page): {content}");
|
||||
assert_eq!(tokens.iter().filter(|t| **t == "re").count(), 1, "one clip, nothing drawn outside it: {content}");
|
||||
assert!(tokens.contains(&"Do"), "the page is drawn inside the clip: {content}");
|
||||
for (args, why) in [
|
||||
(json!({ "doc": doc, "pages": "1-2", "area": [36, 36, 236, 136], "path": "x.pdf" }), "one page"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 36.5, 136], "path": "x.pdf" }), "empty"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 36, 136], "path": "x.pdf" }), "empty"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [5000, 36, 5200, 136], "path": "x.pdf" }), "empty"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 236], "path": "x.pdf" }), "4 numbers"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "multiple", "per_sheet": 2, "path": "x.pdf" }), "Fit"),
|
||||
] {
|
||||
match a.call("doc_print", &args) {
|
||||
Err(ToolError::InvalidArgs(m)) => assert!(m.contains(why), "{why}: {m}"),
|
||||
other => panic!("{why}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Printer marks through the tools: added around the trim box (the page widened to make room),
|
||||
/// removable by kind.
|
||||
#[test]
|
||||
|
||||
@@ -123,6 +123,45 @@ fn page_render_reports_a_recovered_malformed_indexed_image() {
|
||||
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]
|
||||
|
||||
+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;
|
||||
|
||||
@@ -507,17 +507,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
|
||||
@@ -1079,7 +1068,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",
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -384,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> {
|
||||
|
||||
@@ -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
|
||||
+58
-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;
|
||||
@@ -147,9 +148,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 +191,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 +249,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 +290,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 +307,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 +349,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 +364,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 +458,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 +496,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 +526,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 +538,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 +549,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 +608,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 +625,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 +659,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 +677,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 +742,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 +759,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);
|
||||
|
||||
@@ -348,11 +348,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 +395,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 +425,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())]);
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -61,8 +61,9 @@ 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};
|
||||
@@ -93,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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+582
-14
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8096,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();
|
||||
@@ -9684,6 +10012,102 @@ 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.
|
||||
"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.
|
||||
"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",
|
||||
];
|
||||
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 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(), 2, "{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]
|
||||
@@ -12041,16 +12465,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.
|
||||
@@ -13610,6 +14037,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");
|
||||
@@ -14277,6 +14822,29 @@ 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}");
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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))])]);
|
||||
|
||||
@@ -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<_>>())
|
||||
|
||||
+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| {
|
||||
|
||||
@@ -78,9 +78,15 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
|
||||
| `catalog` | Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an `Availability` of `Ready`, `Planned(milestone)` or `Provider`. The UI renders what is *not* built yet, honestly |
|
||||
| `actions` | PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
|
||||
| `links` | Link creation, properties, and finding URLs in page text |
|
||||
| `open_parameters` | A path's PDF open parameters (`manual.pdf#nameddest=Intro`, `#page=12`): the file to open and where to show it. The app and `doc_open` both read them |
|
||||
| `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible |
|
||||
| `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names |
|
||||
|
||||
AcroForm Keystroke, Validate, Calculate and Format scripts run automatically. Their Print,
|
||||
ShowPanel and SaveAs requests are ignored with a JavaScript console diagnostic; field values,
|
||||
alerts and other requests are retained. Explicit button and console scripts can still request
|
||||
printing (#4088).
|
||||
|
||||
## Not done here
|
||||
|
||||
- **Rendering is still borrowed.** `pdfcraft-render` wraps `hayro` and inspects through `lopdf`
|
||||
|
||||
@@ -198,6 +198,9 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"),
|
||||
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
|
||||
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
|
||||
// Find and replace, in Edit text mode (the Find box's Replace row). Not ⌘H/Ctrl+H: macOS
|
||||
// takes ⌘H to hide the app, and Ctrl+H is Read mode on Windows and Linux (as in Acrobat).
|
||||
c("edit.replace", "Replace…", EDIT, Some(Shortcut::cmd_shift("H")), Modification, "replace"),
|
||||
c("edit.advanced_search", "Advanced search…", EDIT, Some(Shortcut::cmd_shift("F")), Document, "search"),
|
||||
c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"),
|
||||
// Page display: View ▸ Page display and the rail's button list the base layouts as radios and
|
||||
|
||||
@@ -167,8 +167,16 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
/// The names of the batches built below L7, which an interface shows in the same places as
|
||||
/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives
|
||||
/// already worded in the interface's language.
|
||||
pub const BATCH_LABELS: &[&str] =
|
||||
&["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"];
|
||||
pub const BATCH_LABELS: &[&str] = &[
|
||||
"Set document title",
|
||||
"Mark differences",
|
||||
"Detect form fields",
|
||||
"Run JavaScript",
|
||||
"Recognize text",
|
||||
field_table::LABEL,
|
||||
"Style",
|
||||
crate::find_replace::REPLACE_ALL,
|
||||
];
|
||||
|
||||
impl Edit {
|
||||
/// Label for the Edit menu and history ("Undo Rotate pages").
|
||||
|
||||
@@ -420,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
if let Some((added_idx, added_item)) = matched_added
|
||||
&& let AddedContent::Image(ai) = added_item.content.clone()
|
||||
{
|
||||
// Page visibility alone is not a crop: dragging an added image over the page edge
|
||||
// must not discard its editable added-item identity on the next resize.
|
||||
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|
||||
|| img.visible_rect.is_some_and(|v| {
|
||||
(v[0] - img.rect[0]).abs() > 1e-4
|
||||
|| (v[1] - img.rect[1]).abs() > 1e-4
|
||||
|| (v[2] - img.rect[2]).abs() > 1e-4
|
||||
|| (v[3] - img.rect[3]).abs() > 1e-4
|
||||
});
|
||||
|| img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4));
|
||||
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
|
||||
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
|
||||
let should_promote = has_active_crop
|
||||
@@ -511,6 +508,14 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After an edit that changes fields, their scripts or the calculation order: the edit's
|
||||
/// script runner reads the document as it now is, and builds its `calculateNow()` list again.
|
||||
fn invalidate_calculations(cx: &mut EditCtx, doc: &pdfcraft_cos::Document) {
|
||||
if let Some(js) = cx.js.as_mut() {
|
||||
js.invalidate_calculations(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform an edit on a working copy (the caller discards it on error).
|
||||
pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
match edit {
|
||||
@@ -539,6 +544,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned());
|
||||
let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?;
|
||||
cx.renamed_fields = outcome.renamed_fields;
|
||||
invalidate_calculations(cx, doc);
|
||||
}
|
||||
Edit::AddBookmark { parent, index, title, page, left, top } => {
|
||||
pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?;
|
||||
@@ -791,22 +797,32 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
Edit::AddField { page, rect, kind, name } => {
|
||||
pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?;
|
||||
invalidate_calculations(cx, doc);
|
||||
}
|
||||
Edit::SetFieldProps { name, props } => {
|
||||
pdfcraft_forms::set_props(doc, name, props)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
}
|
||||
Edit::DuplicateField { name, pages } => {
|
||||
if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 {
|
||||
return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages"))));
|
||||
}
|
||||
invalidate_calculations(cx, doc);
|
||||
}
|
||||
Edit::DeleteField { name } => {
|
||||
pdfcraft_forms::delete_field(doc, name)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
}
|
||||
Edit::DeleteWidget { name, widget } => {
|
||||
pdfcraft_forms::delete_widget(doc, name, *widget)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
}
|
||||
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
|
||||
Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?,
|
||||
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
|
||||
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
|
||||
// As after a script edit: the values the new order gives show at once.
|
||||
Edit::SetCalculationOrder { names } => {
|
||||
pdfcraft_forms::set_calculation_order(doc, names)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::recalculate_with(doc, js)?,
|
||||
None => pdfcraft_forms::recalculate(doc)?,
|
||||
@@ -834,6 +850,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
Edit::SetFieldScript { name, event, script } => {
|
||||
pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?;
|
||||
invalidate_calculations(cx, doc);
|
||||
match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::recalculate_with(doc, js)?,
|
||||
None => pdfcraft_forms::recalculate(doc)?,
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Find and replace in the page text (Edit text ▸ Replace, the `text_replace` tool).
|
||||
//!
|
||||
//! Each match is rewritten inside its own paragraph ([`crate::Document::text_blocks`]) through
|
||||
//! [`Edit::EditTextBlock`], so the paragraph keeps its fonts, sizes and colours and rewraps in its
|
||||
//! box. The paragraph's `editor_text` is matched with the find bar's own rules
|
||||
//! ([`pdfcraft_render::text::find_in_text`]) and the replacement takes the style of the run it
|
||||
//! lands in ([`pdfcraft_edit::TextBlock::replaced`]); every other run keeps its own.
|
||||
//!
|
||||
//! A longer replacement changes the paragraph only, measured with its own fonts' widths, and
|
||||
//! nothing is shrunk or hyphenated to fit. A paragraph of one row grows to the right, up to 36 pt
|
||||
//! from the crop box (a centred line stays in its own box), then wraps; a paragraph of several rows
|
||||
//! keeps its width and rewraps at spaces. New rows go below the last one at the paragraph's line
|
||||
//! spacing and nothing else on the page moves, so they can run over what is below, or off the page,
|
||||
//! without being reported. A word wider than the width stays whole, past the box.
|
||||
//!
|
||||
//! A match is left alone, and counted, when it runs from one paragraph into the next, lies in
|
||||
//! invisible text over a scan, would need another font (the paragraph's own can't draw the new
|
||||
//! text, see [`pdfcraft_edit::LineEdit::substituted`]) or is not page text the paragraphs hold
|
||||
//! (a form field, a comment, text drawn inside a form XObject). Each paragraph is rewritten on a
|
||||
//! copy first, so the edit returned is exactly what will apply: [`Edit::Batch`] for Replace all,
|
||||
//! one undo step.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::edit::check_permission;
|
||||
use crate::{Document, Edit, EditError, guard};
|
||||
|
||||
/// What to look for and what to put in its place, with the find bar's options.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Replace {
|
||||
pub find: String,
|
||||
/// May be empty: the match is deleted.
|
||||
pub with: String,
|
||||
pub case_sensitive: bool,
|
||||
pub whole_words: bool,
|
||||
}
|
||||
|
||||
/// Which matches to replace.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Which {
|
||||
/// Every match on the pages.
|
||||
All,
|
||||
/// The match the find bar highlights on `page`: the centres of its first and last glyphs, in
|
||||
/// view space (points from the top-left of the displayed page, y down: the text layer's).
|
||||
At { page: usize, start: [f32; 2], end: [f32; 2] },
|
||||
}
|
||||
|
||||
impl Which {
|
||||
/// The first match the find bar shows (page text order: pages in order, then reading order),
|
||||
/// as an [`Which::At`] on its first and last glyphs, or `None` when no page has one. `texts`
|
||||
/// are the pages' text, in page order. Replacing "the first match" is replacing this one: if
|
||||
/// no paragraph holds it, [`Document::plan_replace`] reports it as not editable and replaces
|
||||
/// nothing, rather than passing it for a later match that is.
|
||||
pub fn first_in<'a>(texts: impl IntoIterator<Item = (usize, &'a pdfcraft_render::PageText)>, query: &Replace) -> Option<Which> {
|
||||
texts.into_iter().find_map(|(page, text)| {
|
||||
let found = text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).into_iter().next()?;
|
||||
let centre = |g: usize| text.glyphs.get(g).map(|g| [(g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0]);
|
||||
Some(Which::At { page, start: centre(found.start)?, end: centre(found.end.saturating_sub(1))? })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a match was left as it is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Skip {
|
||||
/// It runs from one paragraph into the next.
|
||||
SpansParagraphs,
|
||||
/// It is invisible text over a scanned image (recognized text).
|
||||
ScannedText,
|
||||
/// The paragraph's font can't draw the new text; another font would have stepped in.
|
||||
MissingGlyphs,
|
||||
/// The paragraph couldn't be rewritten (see [`Replacement::refusals`]).
|
||||
Refused,
|
||||
/// It is not in the page's editable text: a form field, a comment, text inside a form XObject.
|
||||
NotEditable,
|
||||
}
|
||||
|
||||
impl Skip {
|
||||
/// How the reason is said after a count ("2 skipped: …"); an interface translates it.
|
||||
pub fn wording(self) -> &'static str {
|
||||
match self {
|
||||
Skip::SpansParagraphs => "spans two paragraphs",
|
||||
Skip::ScannedText => "scanned text",
|
||||
Skip::MissingGlyphs => "the font can't draw it",
|
||||
Skip::Refused => "the paragraph can't be rewritten",
|
||||
Skip::NotEditable => "not editable text",
|
||||
}
|
||||
}
|
||||
|
||||
/// A short name for tools.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Skip::SpansParagraphs => "spans_paragraphs",
|
||||
Skip::ScannedText => "scanned_text",
|
||||
Skip::MissingGlyphs => "missing_glyphs",
|
||||
Skip::Refused => "refused",
|
||||
Skip::NotEditable => "not_editable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every [`Skip::wording`], for an interface to translate.
|
||||
pub const SKIP_WORDINGS: &[&str] =
|
||||
&["spans two paragraphs", "scanned text", "the font can't draw it", "the paragraph can't be rewritten", "not editable text"];
|
||||
|
||||
/// What a replacement would do: the edit (apply it with [`crate::Session::apply`]) and the counts.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Replacement {
|
||||
/// `None` when nothing can be replaced.
|
||||
pub edit: Option<Edit>,
|
||||
/// Matches replaced.
|
||||
pub replaced: usize,
|
||||
/// The pages (0-based, ascending) where something was replaced.
|
||||
pub pages: Vec<usize>,
|
||||
/// Matches left alone, by reason.
|
||||
pub skipped: BTreeMap<Skip, usize>,
|
||||
/// What the engine said about the paragraphs it refused (at most [`MAX_REFUSALS`]).
|
||||
pub refusals: Vec<String>,
|
||||
}
|
||||
|
||||
/// Refusal messages kept in a [`Replacement`].
|
||||
pub const MAX_REFUSALS: usize = 10;
|
||||
|
||||
impl Replacement {
|
||||
pub fn skipped_total(&self) -> usize {
|
||||
self.skipped.values().fold(0, |a, b| a.saturating_add(*b))
|
||||
}
|
||||
|
||||
/// Say that `page`'s text could not be read for the find bar (`why`): its matches can't be
|
||||
/// counted, so one is counted as [`Skip::Refused`] and the page is named in the refusals.
|
||||
pub fn unreadable_page(&mut self, page: usize, why: &str) {
|
||||
refused(self, 1, page, format!("its text could not be read: {why}"));
|
||||
}
|
||||
|
||||
fn skip(&mut self, why: Skip, n: usize) {
|
||||
if n > 0 {
|
||||
let count = self.skipped.entry(why).or_insert(0);
|
||||
*count = count.saturating_add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The label of Replace all in the history (one of [`crate::BATCH_LABELS`]).
|
||||
pub const REPLACE_ALL: &str = "Replace all";
|
||||
|
||||
impl Document {
|
||||
/// The edit that replaces `query`'s matches on `pages` (0-based; others are ignored), and
|
||||
/// what it replaces and leaves. `found` is how many matches the find bar has on each page:
|
||||
/// those no paragraph holds are counted as [`Skip::NotEditable`] (`None`: not counted). The
|
||||
/// document is not changed; the same permission check as Edit text applies.
|
||||
pub fn plan_replace(
|
||||
&self,
|
||||
pages: &[usize],
|
||||
query: &Replace,
|
||||
which: Which,
|
||||
found: Option<&BTreeMap<usize, usize>>,
|
||||
) -> Result<Replacement, EditError> {
|
||||
let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly(self.read_only_reason.clone().unwrap_or_default()))?;
|
||||
// What Edit text checks: the document's permissions.
|
||||
let probe = Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() };
|
||||
if let Some(p) = editor.cos.permissions() {
|
||||
check_permission(&probe, &p)?;
|
||||
}
|
||||
if query.find.split_whitespace().next().is_none() {
|
||||
return Err(EditError::Invalid("there is nothing to find".into()));
|
||||
}
|
||||
let n = self.info.pages.len();
|
||||
let mut pages: Vec<usize> = pages.iter().copied().filter(|p| *p < n).collect();
|
||||
pages.sort_unstable();
|
||||
pages.dedup();
|
||||
if let Which::At { page, .. } = which {
|
||||
pages.retain(|p| *p == page);
|
||||
}
|
||||
let mut out = Replacement::default();
|
||||
let mut work = editor.cos.clone();
|
||||
let mut edits = Vec::new();
|
||||
for page in pages {
|
||||
// A page whose text can't be read is reported, and the others still replaced.
|
||||
let blocks = match pdfcraft_edit::text_blocks(&work, page) {
|
||||
Ok(blocks) => blocks,
|
||||
Err(e) => {
|
||||
refused(&mut out, found.and_then(|f| f.get(&page)).copied().unwrap_or(0), page, e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let matches: Vec<Vec<Range<usize>>> = blocks.iter().map(|b| find(&b.editor_text, query, usize::MAX)).collect();
|
||||
// Paragraph `k`'s matches to replace now.
|
||||
let mut chosen: BTreeMap<usize, Vec<Range<usize>>> = BTreeMap::new();
|
||||
match which {
|
||||
Which::All => {
|
||||
let held = matches.iter().fold(0usize, |a, m| a.saturating_add(m.len()));
|
||||
let spanning = spanning(&blocks, query);
|
||||
out.skip(Skip::SpansParagraphs, spanning);
|
||||
if let Some(f) = found.and_then(|f| f.get(&page)) {
|
||||
out.skip(Skip::NotEditable, f.saturating_sub(held.saturating_add(spanning)));
|
||||
}
|
||||
chosen.extend(matches.into_iter().enumerate().filter(|(_, m)| !m.is_empty()));
|
||||
}
|
||||
Which::At { start, end, .. } => match at(self, &work, page, &blocks, &matches, start, end) {
|
||||
Ok((k, m)) => {
|
||||
chosen.insert(k, vec![m]);
|
||||
}
|
||||
Err(why) => out.skip(why, 1),
|
||||
},
|
||||
}
|
||||
// Last paragraph first, which usually leaves those before it where they were. That is
|
||||
// checked, not assumed: once a paragraph on this page is rewritten, the page is read
|
||||
// again and a paragraph that is no longer what the match was found in is refused.
|
||||
let mut rewrote = false;
|
||||
for (k, ranges) in chosen.into_iter().rev() {
|
||||
let Some(block) = blocks.get(k) else { continue };
|
||||
let count = ranges.len();
|
||||
if rewrote {
|
||||
let same = pdfcraft_edit::text_blocks(&work, page)
|
||||
.ok()
|
||||
.is_some_and(|now| now.get(k).is_some_and(|b| b.editor_text == block.editor_text));
|
||||
if !same {
|
||||
refused(&mut out, count, page, "the page's paragraphs moved after an earlier replacement".into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if block.invisible {
|
||||
out.skip(Skip::ScannedText, count);
|
||||
continue;
|
||||
}
|
||||
let Some((text, sources)) = block.replaced(&ranges, &query.with) else {
|
||||
out.skip(Skip::Refused, count);
|
||||
continue;
|
||||
};
|
||||
if text == block.editor_text {
|
||||
// The replacement is what was there.
|
||||
out.replaced = out.replaced.saturating_add(count);
|
||||
continue;
|
||||
}
|
||||
let style = pdfcraft_edit::BlockStyle { sources, ..Default::default() };
|
||||
let mut trial = work.clone();
|
||||
match guard(|| pdfcraft_edit::rewrite_block(&mut trial, page, k, Some(&text), &style)) {
|
||||
Ok(Ok(pdfcraft_edit::LineEdit { substituted: None })) => {
|
||||
work = trial;
|
||||
rewrote = true;
|
||||
edits.push(Edit::EditTextBlock { page, block: k, text, style });
|
||||
out.replaced = out.replaced.saturating_add(count);
|
||||
if !out.pages.contains(&page) {
|
||||
out.pages.push(page);
|
||||
}
|
||||
}
|
||||
Ok(Ok(pdfcraft_edit::LineEdit { substituted: Some(_) })) => out.skip(Skip::MissingGlyphs, count),
|
||||
Ok(Err(e)) => refused(&mut out, count, page, e.to_string()),
|
||||
Err(panic) => refused(&mut out, count, page, panic),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.pages.sort_unstable();
|
||||
out.edit = match (which, edits.len()) {
|
||||
(_, 0) => None,
|
||||
(Which::All, _) => Some(Edit::Batch { label: REPLACE_ALL.into(), edits }),
|
||||
(Which::At { .. }, _) => edits.pop(),
|
||||
};
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn find(text: &str, query: &Replace, limit: usize) -> Vec<Range<usize>> {
|
||||
pdfcraft_render::text::find_in_text(text, &query.find, query.case_sensitive, query.whole_words, limit)
|
||||
}
|
||||
|
||||
fn refused(out: &mut Replacement, count: usize, page: usize, why: String) {
|
||||
out.skip(Skip::Refused, count);
|
||||
if out.refusals.len() < MAX_REFUSALS {
|
||||
out.refusals.push(format!("page {}: {why}", page.saturating_add(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches that start in one paragraph and end in a later one: the page's paragraphs read one
|
||||
/// after another (a line break between two), as the find bar reads the page.
|
||||
fn spanning(blocks: &[pdfcraft_edit::TextBlock], query: &Replace) -> usize {
|
||||
let mut joined = String::new();
|
||||
let mut ends = Vec::with_capacity(blocks.len());
|
||||
for b in blocks {
|
||||
if !joined.is_empty() {
|
||||
joined.push('\n');
|
||||
}
|
||||
joined.push_str(&b.editor_text);
|
||||
ends.push(joined.len());
|
||||
}
|
||||
// Which paragraph a byte of `joined` is in (a separator counts with the paragraph after it).
|
||||
let paragraph = |byte: usize| ends.partition_point(|end| *end <= byte);
|
||||
find(&joined, query, usize::MAX).into_iter().filter(|r| paragraph(r.start) != paragraph(r.end.saturating_sub(1))).count()
|
||||
}
|
||||
|
||||
/// The paragraph match the find bar's match at `start` … `end` (view space) is: the paragraph
|
||||
/// whose box holds both, and its match nearest `start`.
|
||||
fn at(
|
||||
doc: &Document,
|
||||
cos: &pdfcraft_cos::Document,
|
||||
page: usize,
|
||||
blocks: &[pdfcraft_edit::TextBlock],
|
||||
matches: &[Vec<Range<usize>>],
|
||||
start: [f32; 2],
|
||||
end: [f32; 2],
|
||||
) -> Result<(usize, Range<usize>), Skip> {
|
||||
let info = doc.info.pages.get(page).ok_or(Skip::NotEditable)?;
|
||||
let view = |x: f64, y: f64| info.user_to_view(x as f32, y as f32);
|
||||
// A paragraph's box in view space, a little larger so a glyph's centre on its edge is inside.
|
||||
let holds = |b: &pdfcraft_edit::TextBlock, p: [f32; 2]| {
|
||||
let corners = [view(b.rect[0], b.rect[1]), view(b.rect[2], b.rect[3]), view(b.rect[0], b.rect[3]), view(b.rect[2], b.rect[1])];
|
||||
let pad = (b.size as f32 * 0.3).clamp(1.0, 12.0);
|
||||
let (x0, x1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[0]), b.max(c[0])));
|
||||
let (y0, y1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[1]), b.max(c[1])));
|
||||
p[0] >= x0 - pad && p[0] <= x1 + pad && p[1] >= y0 - pad && p[1] <= y1 + pad
|
||||
};
|
||||
let lines = pdfcraft_edit::text_lines(cos, page).unwrap_or_default();
|
||||
// Where a match's first character is drawn, estimated along its run.
|
||||
let place = |b: &pdfcraft_edit::TextBlock, r: &Range<usize>| -> Option<[f32; 2]> {
|
||||
let (span, line) = b.spans.iter().find(|(s, _)| s.contains(&r.start)).or_else(|| b.spans.iter().find(|(s, _)| s.start >= r.start))?;
|
||||
let line = lines.get(*line)?;
|
||||
let run = b.editor_text.get(span.clone())?;
|
||||
let before = b.editor_text.get(span.start..r.start.max(span.start))?.chars().count() as f64;
|
||||
let total = run.chars().count().max(1) as f64;
|
||||
let along = ((before + 0.5) / total).clamp(0.0, 1.0);
|
||||
let x = line.rect[0] + along * (line.rect[2] - line.rect[0]);
|
||||
Some(view(x, (line.rect[1] + line.rect[3]) / 2.0))
|
||||
};
|
||||
let distance = |p: [f32; 2]| (p[0] - start[0]).hypot(p[1] - start[1]);
|
||||
let mut best: Option<(f32, usize, Range<usize>)> = None;
|
||||
for (k, b) in blocks.iter().enumerate().filter(|(_, b)| holds(b, start)) {
|
||||
for r in matches.get(k).into_iter().flatten() {
|
||||
let d = place(b, r).map_or(f32::MAX, distance);
|
||||
if best.as_ref().is_none_or(|(e, ..)| d < *e) {
|
||||
best = Some((d, k, r.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((_, k, r)) if blocks.get(k).is_some_and(|b| holds(b, end)) => Ok((k, r)),
|
||||
Some(_) => Err(Skip::SpansParagraphs),
|
||||
None if blocks.iter().any(|b| holds(b, start)) && blocks.iter().any(|b| holds(b, end) && !holds(b, start)) => Err(Skip::SpansParagraphs),
|
||||
None => Err(Skip::NotEditable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rewrite_order_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Replacing in several paragraphs of one page: each is checked against the page as it is after
|
||||
//! the earlier rewrites, and a paragraph that still reads as it was found is replaced.
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::{DocId, Session};
|
||||
|
||||
fn page_with(content: &str) -> Vec<u8> {
|
||||
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 595 842] /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 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(bytes: Vec<u8>) -> (Session, DocId) {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("order.pdf", None, Arc::new(bytes), None).expect("opens");
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_in_every_paragraph_of_a_page_still_replaces_each_one() {
|
||||
let (mut s, id) = open(page_with("BT /F1 12 Tf 72 700 Td (red apple) Tj 0 -60 Td (red pear) Tj 0 -60 Td (red plum) Tj ET"));
|
||||
let q = Replace { find: "red".into(), with: "green".into(), ..Default::default() };
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap();
|
||||
assert_eq!(plan.replaced, 3, "refusals: {:?}", plan.refusals);
|
||||
assert!(plan.refusals.is_empty(), "{:?}", plan.refusals);
|
||||
let edit = plan.edit.expect("an edit");
|
||||
s.apply(id, edit).unwrap();
|
||||
let texts: Vec<String> = s.get(id).unwrap().text_blocks(0).into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["green apple", "green pear", "green plum"]);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::{DocId, Session};
|
||||
|
||||
/// A one-page document with `content` and two fonts: `F1` Helvetica, `F2` Helvetica-Bold, and
|
||||
/// `F3` a subset of a face no computer has, with glyphs for `a` and `b` only.
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
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 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NoSuchFace /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>"
|
||||
.into(),
|
||||
];
|
||||
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(bytes: Vec<u8>) -> (Session, DocId) {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("replace.pdf", None, Arc::new(bytes), None).expect("opens");
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn query(find: &str, with: &str) -> Replace {
|
||||
Replace { find: find.into(), with: with.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Plan and apply; the plan's counts.
|
||||
fn replace_all(s: &mut Session, id: DocId, q: &Replace) -> Replacement {
|
||||
let pages: Vec<usize> = (0..s.get(id).unwrap().info.pages.len()).collect();
|
||||
let plan = s.get(id).unwrap().plan_replace(&pages, q, Which::All, None).unwrap();
|
||||
if let Some(edit) = plan.edit.clone() {
|
||||
s.apply(id, edit).unwrap();
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
fn texts(s: &Session, id: DocId, page: usize) -> Vec<String> {
|
||||
s.get(id).unwrap().text_blocks(page).into_iter().map(|b| b.text).collect()
|
||||
}
|
||||
|
||||
/// The page text as the find bar reads it (the text layer's glyphs).
|
||||
fn page_text(s: &Session, id: DocId, page: usize) -> pdfcraft_render::PageText {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
out.text.map(|t| t.as_ref().clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_word_keeps_its_paragraphs_font_and_size() {
|
||||
let (mut s, id) = open(pdf("BT /F1 14 Tf 72 700 Td (The quick brown fox) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("quick", "slow"));
|
||||
assert_eq!((plan.replaced, plan.pages.clone(), plan.skipped_total()), (1, vec![0], 0));
|
||||
assert_eq!(texts(&s, id, 0), ["The slow brown fox"]);
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!((lines[0].font.as_str(), lines[0].base_font.as_str(), lines[0].size), ("F1", "Helvetica", 14.0));
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Replace all"));
|
||||
}
|
||||
|
||||
/// A red word and a bold name inside a paragraph keep their own style when words around them
|
||||
/// are replaced; a replacement inside the red word stays red.
|
||||
#[test]
|
||||
fn a_mixed_style_paragraph_keeps_the_other_runs_styles() {
|
||||
let content = "BT /F1 12 Tf 72 700 Td 0 g (Please pay the invoice ) Tj 1 0 0 rg (now) Tj 0 g (, before the end of the month,) Tj ET
|
||||
BT /F1 12 Tf 72 686 Td 0 g (as agreed with ) Tj /F2 12 Tf (Ada Lovelace) Tj /F1 12 Tf (.) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0).len(), 1, "one paragraph of five runs");
|
||||
let q = Replace { whole_words: true, ..query("the", "THE") };
|
||||
let plan = replace_all(&mut s, id, &q);
|
||||
assert_eq!(plan.replaced, 3);
|
||||
let plan = replace_all(&mut s, id, &query("now", "today"));
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert_eq!(texts(&s, id, 0), ["Please pay THE invoice today, before THE end of THE month, as agreed with Ada Lovelace."]);
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("no run with {word:?}: {lines:?}"));
|
||||
assert_eq!(run("today").color, [1.0, 0.0, 0.0], "the replacement takes the red run it lands in");
|
||||
assert_eq!(run("today").text.trim(), "today", "and only it is red");
|
||||
assert_eq!(run("Ada").base_font, "Helvetica-Bold");
|
||||
assert_eq!(run("Ada").text.trim(), "Ada Lovelace");
|
||||
assert_eq!(run("invoice").color, [0.0, 0.0, 0.0]);
|
||||
assert_eq!(run("invoice").base_font, "Helvetica");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_all_across_pages_is_one_undo_step() {
|
||||
let (mut s, id) = open(crate::tests::fixture(3));
|
||||
let before = s.get(id).unwrap().bytes.clone();
|
||||
let plan = replace_all(&mut s, id, &query("page", "Sheet"));
|
||||
assert_eq!((plan.replaced, plan.pages.clone()), (3, vec![0, 1, 2]));
|
||||
assert!(matches!(&plan.edit, Some(Edit::Batch { edits, .. }) if edits.len() == 3));
|
||||
for p in 0..3 {
|
||||
assert_eq!(texts(&s, id, p), [format!("Sheet {}", p + 1)]);
|
||||
}
|
||||
s.undo(id).unwrap();
|
||||
for p in 0..3 {
|
||||
assert_eq!(texts(&s, id, p), [format!("Page {}", p + 1)]);
|
||||
}
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), None, "one step undid all three pages");
|
||||
assert_eq!(s.get(id).unwrap().bytes, before, "the original bytes are back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_words_and_case_follow_the_find_options() {
|
||||
let content = "BT /F1 12 Tf 72 700 Td (cat Cats concat CAT) Tj ET";
|
||||
let count = |q: Replace| {
|
||||
let (s, id) = open(pdf(content));
|
||||
s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap().replaced
|
||||
};
|
||||
assert_eq!(count(query("cat", "dog")), 4);
|
||||
assert_eq!(count(Replace { whole_words: true, ..query("cat", "dog") }), 2);
|
||||
assert_eq!(count(Replace { case_sensitive: true, ..query("cat", "dog") }), 2);
|
||||
assert_eq!(count(Replace { case_sensitive: true, whole_words: true, ..query("CAT", "dog") }), 1);
|
||||
let (mut s, id) = open(pdf(content));
|
||||
replace_all(&mut s, id, &Replace { whole_words: true, ..query("cat", "dog") });
|
||||
assert_eq!(texts(&s, id, 0), ["dog Cats concat dog"]);
|
||||
// An empty replacement deletes the match.
|
||||
replace_all(&mut s, id, &query(" concat", ""));
|
||||
assert_eq!(texts(&s, id, 0), ["dog Cats dog"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_spanning_two_paragraphs_is_skipped_and_counted() {
|
||||
let content = "BT /F1 12 Tf 72 700 Td (The first ends here) Tj ET BT /F1 12 Tf 72 500 Td (second starts there) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0).len(), 2);
|
||||
let plan = replace_all(&mut s, id, &query("here second", "x"));
|
||||
assert_eq!(plan.replaced, 0);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::SpansParagraphs, 1)]));
|
||||
assert!(plan.edit.is_none());
|
||||
assert_eq!(texts(&s, id, 0), ["The first ends here", "second starts there"]);
|
||||
}
|
||||
|
||||
/// The subset in `F3` has glyphs for `a` and `b` only, from a face no computer has: a `c` would
|
||||
/// be drawn in Helvetica instead. That paragraph is left alone and counted, the other replaced.
|
||||
#[test]
|
||||
fn a_character_the_font_cannot_draw_is_skipped_and_counted() {
|
||||
let content = "BT /F3 12 Tf 72 700 Td (ab ba) Tj ET BT /F1 12 Tf 72 500 Td (ab in Helvetica) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
let before = texts(&s, id, 0);
|
||||
assert_eq!(before, ["ab ba", "ab in Helvetica"]);
|
||||
let plan = replace_all(&mut s, id, &query("ab", "cb"));
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)]));
|
||||
assert_eq!(texts(&s, id, 0), ["ab ba", "cb in Helvetica"]);
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert!(lines.iter().filter(|l| l.text.contains("ab ba")).all(|l| l.font == "F3"), "the subset paragraph keeps its font: {lines:?}");
|
||||
// What the subset can draw still goes in.
|
||||
let plan = replace_all(&mut s, id, &query("ab ba", "ba ab"));
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (1, 0));
|
||||
assert_eq!(texts(&s, id, 0)[0], "ba ab");
|
||||
}
|
||||
|
||||
/// The styled rewrite (a paragraph of several runs) reports the missing character as the
|
||||
/// one-style rewrite above does.
|
||||
#[test]
|
||||
fn a_character_the_font_cannot_draw_is_skipped_in_a_paragraph_of_several_runs() {
|
||||
let (mut s, id) = open(pdf("BT /F3 12 Tf 72 700 Td (ab ) Tj /F1 12 Tf (and Helvetica) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("ab", "cb"));
|
||||
assert_eq!(plan.replaced, 0);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)]));
|
||||
assert!(plan.edit.is_none());
|
||||
}
|
||||
|
||||
/// A character Helvetica has no code for is never written as a box or a `?`: whether another font
|
||||
/// has it or none does, the match is left alone and reported.
|
||||
#[test]
|
||||
fn a_replacement_outside_the_fonts_encoding_is_reported_and_changes_nothing() {
|
||||
for with in ["\u{65E5}\u{672C}", "\u{1F600}"] {
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("cat", with));
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (0, 1), "{with}: {plan:?}");
|
||||
assert!(plan.edit.is_none(), "{with}");
|
||||
assert_eq!(texts(&s, id, 0), ["one cat"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rects of the page's text lines holding `word`.
|
||||
fn line_rects(s: &Session, id: DocId, word: &str) -> Vec<[f64; 4]> {
|
||||
s.get(id).unwrap().text_lines(0).into_iter().filter(|l| l.text.contains(word)).map(|l| l.rect).collect()
|
||||
}
|
||||
|
||||
/// A paragraph of several rows keeps its width and gains rows below; what is under it stays where
|
||||
/// it was, so the new rows run over it.
|
||||
#[test]
|
||||
fn a_longer_replacement_adds_rows_and_moves_nothing_else() {
|
||||
let content =
|
||||
"BT /F1 12 Tf 14 TL 72 700 Td (alpha beta gamma delta) Tj T* (epsilon zeta eta theta) Tj ET BT /F1 12 Tf 72 650 Td (next paragraph) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0).len(), 2);
|
||||
let right = s.get(id).unwrap().text_blocks(0)[0].rect[2];
|
||||
let next = line_rects(&s, id, "next paragraph");
|
||||
let with = "a much longer replacement that takes several more rows than the paragraph has";
|
||||
let plan = replace_all(&mut s, id, &query("beta", with));
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (1, 0));
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
let rows: Vec<_> = lines.iter().filter(|l| !l.text.contains("next paragraph")).collect();
|
||||
assert!(rows.len() >= 5, "{rows:?}");
|
||||
assert!(rows.iter().all(|l| l.rect[2] <= right + 1.0), "the rows keep the width: {rows:?}");
|
||||
assert!(rows.iter().any(|l| l.rect[1] < next[0][1]), "the new rows go past what was below: {rows:?}");
|
||||
assert_eq!(line_rects(&s, id, "next paragraph"), next);
|
||||
}
|
||||
|
||||
/// One row grows to the right up to the margin, then wraps; a word wider than that stays whole.
|
||||
#[test]
|
||||
fn a_longer_replacement_grows_one_row_to_the_margin() {
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
|
||||
replace_all(&mut s, id, &query("short", "a line far wider than the word it took the place of"));
|
||||
assert_eq!(s.get(id).unwrap().text_lines(0).len(), 1);
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
|
||||
replace_all(&mut s, id, &query("short", &"many words in a row ".repeat(8)));
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert!(lines.len() > 1 && lines.iter().all(|l| l.rect[2] <= 595.0 - 36.0 + 1.0), "{lines:?}");
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
|
||||
replace_all(&mut s, id, &query("short", &"w".repeat(80)));
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].rect[2] > 595.0, "the word runs past the page: {lines:?}");
|
||||
}
|
||||
|
||||
/// A match can run over a row's end hyphen, typed as the find bar reads it (the break is a space);
|
||||
/// Replace all then folds case, joins the rows and counts what the find bar counts. The word read
|
||||
/// unhyphenated is also a find-bar match, but no paragraph's text holds it: left alone and counted.
|
||||
#[test]
|
||||
fn case_insensitive_replace_all_takes_a_match_across_a_hyphenated_row_end() {
|
||||
let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE and an Exam-) Tj T* (ple to end) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0), ["An EXAMPLE and an Example to end"]);
|
||||
let text = page_text(&s, id, 0);
|
||||
let whole = query("example", "sample");
|
||||
let found = BTreeMap::from([(0, text.find_opts(&whole.find, false, false).len())]);
|
||||
assert_eq!(found[&0], 2);
|
||||
let none = s.get(id).unwrap().plan_replace(&[0], &whole, Which::All, Some(&found)).unwrap();
|
||||
assert_eq!((none.replaced, none.skipped_total(), none.edit.is_none()), (0, 2, true));
|
||||
let q = query("exam- ple", "sample");
|
||||
let found = BTreeMap::from([(0, text.find_opts(&q.find, false, false).len())]);
|
||||
assert_eq!(found[&0], 2);
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, Some(&found)).unwrap();
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (2, 0));
|
||||
s.apply(id, plan.edit.unwrap()).unwrap();
|
||||
assert_eq!(texts(&s, id, 0), ["An sample and an sample to end"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_text_over_a_scan_is_skipped() {
|
||||
let (mut s, id) = open(pdf("BT 3 Tr /F1 12 Tf 72 700 Td (recognized words) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("words", "text"));
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::ScannedText, 1)]));
|
||||
assert!(plan.edit.is_none());
|
||||
}
|
||||
|
||||
/// What the find bar counts on a page beyond what the paragraphs hold is reported as not
|
||||
/// editable text (a form field, a comment), so the counts add up to what was highlighted.
|
||||
#[test]
|
||||
fn matches_the_paragraphs_dont_hold_are_counted_as_not_editable() {
|
||||
let (s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET"));
|
||||
let found = BTreeMap::from([(0, 3)]);
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), Which::All, Some(&found)).unwrap();
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 2)]));
|
||||
}
|
||||
|
||||
/// Replace (one match): the highlighted one, found by where the find bar's glyphs are, even when
|
||||
/// the paragraph holds the word twice.
|
||||
#[test]
|
||||
fn the_highlighted_match_is_the_one_replaced() {
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (cat and cat) Tj ET BT /F1 12 Tf 72 500 Td (no match) Tj ET"));
|
||||
let text = page_text(&s, id, 0);
|
||||
let found = text.find("cat");
|
||||
assert_eq!(found.len(), 2);
|
||||
let centre = |g: usize| {
|
||||
let r = text.glyphs[g].rect;
|
||||
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
|
||||
};
|
||||
let second = &found[1];
|
||||
let which = Which::At { page: 0, start: centre(second.start), end: centre(second.end - 1) };
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), which, None).unwrap();
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert!(matches!(&plan.edit, Some(Edit::EditTextBlock { block: 0, .. })), "one paragraph edit, not a batch: {:?}", plan.edit);
|
||||
s.apply(id, plan.edit.unwrap()).unwrap();
|
||||
assert_eq!(texts(&s, id, 0), ["cat and dog", "no match"]);
|
||||
// A point in no paragraph is not editable text.
|
||||
let away = Which::At { page: 0, start: [500.0, 20.0], end: [510.0, 20.0] };
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), away, None).unwrap();
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_replaces_only_the_first_match_in_document_order() {
|
||||
let (mut s, id) = open(crate::tests::fixture(2));
|
||||
let q = query("page", "Sheet");
|
||||
let texts_of: Vec<_> = (0..2).map(|p| page_text(&s, id, p)).collect();
|
||||
let which = Which::first_in(texts_of.iter().enumerate(), &q).expect("the find bar has a match");
|
||||
assert!(matches!(which, Which::At { page: 0, .. }));
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0, 1], &q, which, None).unwrap();
|
||||
assert_eq!((plan.replaced, plan.pages.clone()), (1, vec![0]));
|
||||
s.apply(id, plan.edit.unwrap()).unwrap();
|
||||
assert_eq!((texts(&s, id, 0), texts(&s, id, 1)), (vec!["Sheet 1".to_string()], vec!["Page 2".to_string()]));
|
||||
assert!(Which::first_in(texts_of.iter().enumerate(), &query("absent", "x")).is_none());
|
||||
}
|
||||
|
||||
/// The first match is the find bar's, not the first one a paragraph holds: when it is a match no
|
||||
/// paragraph holds (a word split by a line-end hyphen), it is reported and nothing is replaced,
|
||||
/// not the later paragraph that has the word whole.
|
||||
#[test]
|
||||
fn first_does_not_skip_a_match_no_paragraph_holds_for_a_later_one() {
|
||||
let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE to end) Tj ET BT /F1 12 Tf 72 400 Td (a second example) Tj ET";
|
||||
let (s, id) = open(pdf(content));
|
||||
let q = query("example", "sample");
|
||||
let text = page_text(&s, id, 0);
|
||||
assert_eq!(text.find_opts("example", false, false).len(), 2, "the find bar has both");
|
||||
let which = Which::first_in([(0, &text)], &q).unwrap();
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &q, which, None).unwrap();
|
||||
assert_eq!((plan.replaced, plan.edit.is_none()), (0, true), "{plan:?}");
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)]));
|
||||
assert_eq!(texts(&s, id, 0).last().unwrap(), "a second example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_find_is_refused() {
|
||||
let (s, id) = open(crate::tests::fixture(1));
|
||||
assert!(matches!(s.get(id).unwrap().plan_replace(&[0], &query(" ", "x"), Which::All, None), Err(EditError::Invalid(_))));
|
||||
}
|
||||
+106
-17
@@ -81,20 +81,26 @@ impl JsOutput {
|
||||
}
|
||||
|
||||
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
||||
/// except a request to show a side panel or open Save As, which is noted on the console
|
||||
/// and dropped.
|
||||
/// except a request to print, show a side panel, open Save As, or open or save an attached
|
||||
/// file, which is noted on the console and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
self.alert(o.alerts.iter().cloned());
|
||||
self.say(o.console.iter().cloned());
|
||||
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
|
||||
// button's may show or hide a side panel, or open Save As.
|
||||
// button's or the console's may print, show or hide a side panel, open Save As, or open
|
||||
// or save an attached file (#4088).
|
||||
// The warnings go through `say` like any other console line, so they count against the
|
||||
// line and byte caps instead of growing the console past them.
|
||||
let mut notes = Vec::new();
|
||||
for r in &o.requests {
|
||||
match r {
|
||||
Request::Print => notes
|
||||
.push("Print requested by an automatic field script was ignored; use a button or the JavaScript console to print".to_string()),
|
||||
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
|
||||
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
|
||||
Request::OpenAttachment { name, .. } => {
|
||||
notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file"))
|
||||
}
|
||||
r => self.requests.push(r.clone()),
|
||||
}
|
||||
}
|
||||
@@ -221,7 +227,11 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState
|
||||
}
|
||||
|
||||
/// The changes a script made, against the fields as they were.
|
||||
fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]) -> Vec<FieldChange> {
|
||||
fn changes(
|
||||
before: &[pdfcraft_js::FieldState],
|
||||
after: &[pdfcraft_js::FieldState],
|
||||
formatted: &std::collections::BTreeSet<String>,
|
||||
) -> Vec<FieldChange> {
|
||||
if after.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -238,6 +248,7 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]
|
||||
let c = FieldChange {
|
||||
name: a.name.clone(),
|
||||
value: (a.value != b.value).then(|| a.value.clone()),
|
||||
formatted: a.value != b.value && formatted.contains(&a.name),
|
||||
read_only: (a.readonly != b.readonly).then_some(a.readonly),
|
||||
required: (a.required != b.required).then_some(a.required),
|
||||
display: (a.display != b.display).then_some(a.display),
|
||||
@@ -260,6 +271,7 @@ pub struct JsRunner {
|
||||
// otherwise reparse every widget for every calculation. Values/flags still come from
|
||||
// the live `fields` snapshot on each call. A new edit gets a new runner and cache.
|
||||
displays: std::collections::HashMap<pdfcraft_cos::ObjRef, i32>,
|
||||
calculations: Option<std::sync::Arc<Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
impl JsRunner {
|
||||
@@ -268,12 +280,15 @@ impl JsRunner {
|
||||
/// highlights form fields (`app.runtimeHighlight`).
|
||||
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner {
|
||||
let xfa = pdfcraft_xfa::xfa_shape(cos).is_some();
|
||||
let pages = pdfcraft_model::pages(cos);
|
||||
let doc = pdfcraft_js::DocInfo {
|
||||
file_name: file_name.to_string(),
|
||||
num_pages: pdfcraft_model::pages(cos).len(),
|
||||
num_pages: pages.len(),
|
||||
page: 0,
|
||||
info: info(cos),
|
||||
highlight,
|
||||
page_boxes: pdfcraft_organize::page_boxes(cos).unwrap_or_default(),
|
||||
page_rotations: pages.iter().map(|page| page.rotation(cos)).collect(),
|
||||
xfa,
|
||||
};
|
||||
// Once XFA owns the form, the PDF container's scripts belong to its fallback view.
|
||||
@@ -282,8 +297,61 @@ impl JsRunner {
|
||||
// Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts.
|
||||
let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some();
|
||||
let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) };
|
||||
JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() }
|
||||
JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new(), calculations: None }
|
||||
}
|
||||
|
||||
fn calculations(&mut self, fields: &[Field]) -> std::sync::Arc<Vec<(String, String)>> {
|
||||
if let Some(calculations) = &self.calculations {
|
||||
return std::sync::Arc::clone(calculations);
|
||||
}
|
||||
let calculations = std::sync::Arc::new(calculation_scripts(&self.cos, fields));
|
||||
self.calculations = Some(std::sync::Arc::clone(&calculations));
|
||||
calculations
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_calculations(&mut self, cos: &pdfcraft_cos::Document) {
|
||||
self.cos = cos.clone();
|
||||
self.displays.clear();
|
||||
self.calculations = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(String, String)> {
|
||||
#[cfg(test)]
|
||||
CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(calls.get().saturating_add(1)));
|
||||
pdfcraft_forms::calculation_order(cos, fields)
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|ordered| {
|
||||
let field = fields.iter().find(|f| f.name == ordered.name)?;
|
||||
let script = field.actions.scripts.calculate.clone().or_else(|| {
|
||||
let source = pdfcraft_forms::af::calculate_js(&field.actions.calculate)?;
|
||||
Some(match &field.actions.calculate {
|
||||
pdfcraft_forms::af::Calculate::Simple { .. } => format!("event.value = {source}"),
|
||||
_ => source,
|
||||
})
|
||||
})?;
|
||||
Some((field.name.clone(), script))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static CALCULATION_SCRIPT_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn calls_calculate_now(script: &str, doc_scripts: &[String]) -> bool {
|
||||
script.contains("calculateNow") || doc_scripts.iter().any(|source| source.contains("calculateNow"))
|
||||
}
|
||||
|
||||
fn calculations_for_script(
|
||||
cos: &pdfcraft_cos::Document,
|
||||
fields: &[Field],
|
||||
script: &str,
|
||||
doc_scripts: &[String],
|
||||
) -> std::sync::Arc<Vec<(String, String)>> {
|
||||
if calls_calculate_now(script, doc_scripts) { std::sync::Arc::new(calculation_scripts(cos, fields)) } else { std::sync::Arc::new(Vec::new()) }
|
||||
}
|
||||
|
||||
/// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`).
|
||||
@@ -340,7 +408,16 @@ impl JsRunner {
|
||||
.collect();
|
||||
let states = std::sync::Arc::new(states);
|
||||
let ev = pdfcraft_js::Event::field(event.name(), &target.name, value);
|
||||
let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default());
|
||||
let calculations = if calls_calculate_now(script, &self.doc_scripts) { self.calculations(fields) } else { std::sync::Arc::new(Vec::new()) };
|
||||
let o = pdfcraft_js::run_shared_calculations_shared(
|
||||
script,
|
||||
&ev,
|
||||
&self.doc,
|
||||
std::sync::Arc::clone(&states),
|
||||
&self.doc_scripts,
|
||||
&calculations,
|
||||
Limits::default(),
|
||||
);
|
||||
if log {
|
||||
self.output.absorb(&o);
|
||||
}
|
||||
@@ -355,7 +432,7 @@ impl JsRunner {
|
||||
rc: o.rc,
|
||||
value: o.value.clone(),
|
||||
value_set: o.value_set,
|
||||
changes: changes(&states, &o.changed),
|
||||
changes: changes(&states, &o.changed, &o.formatted),
|
||||
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
|
||||
})
|
||||
}
|
||||
@@ -510,10 +587,12 @@ impl Session {
|
||||
}
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||||
// A button PdfCraft generated from an XFA template runs its XFA click script.
|
||||
// A button PdfCraft generated from an XFA template runs its XFA click script (the app runs
|
||||
// click scripts for buttons only, so other fields' click events don't run here either).
|
||||
if let Some(t) = target
|
||||
&& let Some(tpl) = doc.xfa_template.clone()
|
||||
&& let Some(field) = doc.form.iter().find(|f| f.name == t)
|
||||
&& field.kind == crate::FormFieldKind::PushButton
|
||||
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field)
|
||||
{
|
||||
// The click returns only the output of its own edit; output from opening or from earlier edits stays.
|
||||
@@ -529,13 +608,22 @@ impl Session {
|
||||
}
|
||||
let runner = JsRunner::new(&cos, &doc.name, self.field_highlight);
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect();
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f)).collect::<Vec<_>>());
|
||||
let event = match target {
|
||||
Some(t) => pdfcraft_js::Event { will_commit: false, ..pdfcraft_js::Event::field("Mouse Up", t, "") },
|
||||
None => pdfcraft_js::Event::doc("Console"),
|
||||
};
|
||||
let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default());
|
||||
let changes = changes(&states, &o.changed);
|
||||
let calculations = calculations_for_script(&cos, &fields, script, &runner.doc_scripts);
|
||||
let mut o = pdfcraft_js::run_shared_calculations_shared(
|
||||
script,
|
||||
&event,
|
||||
&runner.doc,
|
||||
std::sync::Arc::clone(&states),
|
||||
&runner.doc_scripts,
|
||||
&calculations,
|
||||
Limits::default(),
|
||||
);
|
||||
let changes = changes(&states, &o.changed, &o.formatted);
|
||||
let resets: Vec<Vec<String>> = o
|
||||
.requests
|
||||
.iter()
|
||||
@@ -874,8 +962,9 @@ mod review_tests {
|
||||
second.value = vec!["second".into()];
|
||||
second.display = pdfcraft_js::DISPLAY_HIDDEN;
|
||||
let before = vec![first.clone(), second.clone()];
|
||||
assert!(changes(&before, std::slice::from_ref(&first)).is_empty());
|
||||
let actual = changes(&before, std::slice::from_ref(&second));
|
||||
let formatted = std::collections::BTreeSet::new();
|
||||
assert!(changes(&before, std::slice::from_ref(&first), &formatted).is_empty());
|
||||
let actual = changes(&before, std::slice::from_ref(&second), &formatted);
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![FieldChange {
|
||||
@@ -897,12 +986,12 @@ mod review_tests {
|
||||
assert_eq!(outcome.value, "first");
|
||||
assert_eq!(outcome.changed.len(), 1);
|
||||
assert_eq!(
|
||||
changes(&before, &outcome.changed),
|
||||
changes(&before, &outcome.changed, &outcome.formatted),
|
||||
vec![FieldChange { name: "duplicate".into(), value: Some(vec!["updated".into()]), ..Default::default() }]
|
||||
);
|
||||
assert!(changes(&before, &[]).is_empty());
|
||||
assert!(changes(&before, &[], &formatted).is_empty());
|
||||
let mut missing = first;
|
||||
missing.name = "missing".into();
|
||||
assert!(changes(&before, &[missing]).is_empty());
|
||||
assert!(changes(&before, &[missing], &formatted).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod error;
|
||||
pub mod export;
|
||||
pub mod field_table;
|
||||
mod fill_sign;
|
||||
pub mod find_replace;
|
||||
mod history;
|
||||
pub mod js;
|
||||
mod layers;
|
||||
@@ -40,6 +41,7 @@ pub mod model3d;
|
||||
mod object_clip;
|
||||
pub mod ocr;
|
||||
mod open;
|
||||
pub mod open_parameters;
|
||||
pub mod optimizer;
|
||||
mod page_filter;
|
||||
mod pictures;
|
||||
@@ -54,6 +56,7 @@ mod session;
|
||||
pub mod signature_image;
|
||||
pub mod signature_scan;
|
||||
mod sources;
|
||||
pub mod spooler;
|
||||
mod stamp_preview;
|
||||
#[cfg(test)]
|
||||
mod test_hooks;
|
||||
|
||||
@@ -2321,4 +2321,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first and last letters of a line are read: ocrs' own 3-pixel margin clipped them at
|
||||
/// 300 dpi ("The" read as "he", "and" as "an", full stops lost, about 4 % of the words).
|
||||
/// Needs the OCR models (`cargo xtask models`).
|
||||
#[test]
|
||||
fn the_first_and_last_letters_of_a_line_are_read() {
|
||||
// The default engine is ocrs: Tesseract alone makes `available()` true but cannot run it.
|
||||
if Models::find().is_none() {
|
||||
eprintln!("skipped: OCR models not installed");
|
||||
return;
|
||||
}
|
||||
let paragraph = "The annual report summarizes revenue, operating costs and the outlook for the coming year. \
|
||||
Sales in the northern region grew by twelve percent, while the southern offices remained flat. \
|
||||
Management expects stable margins despite higher prices for energy and transport. ";
|
||||
let text = paragraph.repeat(6);
|
||||
let s = Session::new();
|
||||
let pdf = s.create_from_text("t", &text).unwrap();
|
||||
let mut viewer = Session::new();
|
||||
let id = viewer.open("text.pdf", None, pdf, None).unwrap();
|
||||
assert_eq!(viewer.get(id).unwrap().info.pages.len(), 1, "the text fits one page");
|
||||
let png = export::Exporter::new(viewer.get(id).unwrap()).png(0, 300.0).unwrap();
|
||||
let scan = s.create_from_images(&[("1.png".into(), png)]).unwrap();
|
||||
let mut reader = Session::new();
|
||||
let id = reader.open("scan.pdf", None, scan, None).unwrap();
|
||||
let read = reader.recognize_text(id, &[], OcrSettings::default()).unwrap()[0].text();
|
||||
let (got, want): (Vec<&str>, Vec<&str>) = (read.split_whitespace().collect(), text.split_whitespace().collect());
|
||||
// Word-level edit distance.
|
||||
let mut row: Vec<usize> = (0..=want.len()).collect();
|
||||
for g in &got {
|
||||
let mut prev = row[0];
|
||||
row[0] += 1;
|
||||
for (j, w) in want.iter().enumerate() {
|
||||
let next = (prev + usize::from(g != w)).min(row[j] + 1).min(row[j + 1] + 1);
|
||||
prev = row[j + 1];
|
||||
row[j + 1] = next;
|
||||
}
|
||||
}
|
||||
let errors = row[want.len()];
|
||||
assert!(errors * 100 <= want.len(), "{errors} word errors in {} words:\n{read}", want.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! PDF open parameters on a path (#3425): help systems and other programs start a PDF reader with
|
||||
//! `manual.pdf#nameddest=Intro` or `manual.pdf#page=12` to show one place in a document. The
|
||||
//! parameters follow Adobe's public "Parameters for Opening PDF Files": `name=value` pairs after a
|
||||
//! `#`, separated by `&` or `#`. PdfCraft reads `page` (1-based) and `nameddest`, and, as browsers'
|
||||
//! PDF viewers do, a fragment without `=` as a destination name. Other parameters (`zoom`, `view`,
|
||||
//! `pagemode`, `search`, …) are ignored, and the file still opens.
|
||||
|
||||
use pdfcraft_render::{DestView, RemoteDestination};
|
||||
|
||||
/// How many `#` of one path are tried as the end of its file name.
|
||||
const MAX_CANDIDATES: usize = 32;
|
||||
|
||||
/// The file `path` names and the destination its open parameters ask for, when `path` is not a
|
||||
/// file itself but what comes before one of its `#` is. The first such `#` counts, so a folder or
|
||||
/// file name may contain `#` too. `None`: open `path` as it is.
|
||||
pub fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> {
|
||||
if is_file(path) {
|
||||
return None;
|
||||
}
|
||||
let (file, fragment) = path.match_indices('#').take(MAX_CANDIDATES).find_map(|(i, _)| {
|
||||
// `#` is one byte, so `i` and `i + 1` are char boundaries.
|
||||
let file = path.get(..i)?;
|
||||
is_file(file).then(|| (file, path.get(i + 1..).unwrap_or_default()))
|
||||
})?;
|
||||
Some((file.to_string(), destination(fragment)))
|
||||
}
|
||||
|
||||
/// The destination of a fragment's parameters; the last `page` or `nameddest` wins, as each
|
||||
/// parameter is applied in turn. A page before the first, or one that isn't a number, is ignored.
|
||||
fn destination(fragment: &str) -> RemoteDestination {
|
||||
if !fragment.contains('=') {
|
||||
return if fragment.is_empty() { RemoteDestination::Default } else { RemoteDestination::Named(fragment.to_string()) };
|
||||
}
|
||||
let mut destination = RemoteDestination::Default;
|
||||
for (key, value) in fragment.split(['&', '#']).filter_map(|p| p.split_once('=')) {
|
||||
if key.eq_ignore_ascii_case("page") {
|
||||
if let Some(page) = value.trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1)) {
|
||||
destination = RemoteDestination::Page { page, view: DestView::Top };
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("nameddest") && !value.is_empty() {
|
||||
destination = RemoteDestination::Named(value.to_string());
|
||||
}
|
||||
}
|
||||
destination
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn files(names: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||
move |p| names.contains(&p)
|
||||
}
|
||||
|
||||
fn page(page: usize) -> RemoteDestination {
|
||||
RemoteDestination::Page { page, view: DestView::Top }
|
||||
}
|
||||
|
||||
fn named(name: &str) -> RemoteDestination {
|
||||
RemoteDestination::Named(name.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_destination_or_page_after_the_file_name_is_split_off() {
|
||||
let manual = files(&[r"C:\Help\manual.pdf"]);
|
||||
// #3425: DIgSILENT PowerFactory's F1 help.
|
||||
assert_eq!(split(r"C:\Help\manual.pdf#nameddest=Load Flow", &manual), Some((r"C:\Help\manual.pdf".into(), named("Load Flow"))));
|
||||
assert_eq!(split(r"C:\Help\manual.pdf#page=12", &manual), Some((r"C:\Help\manual.pdf".into(), page(11))));
|
||||
// A bare fragment names a destination, as in a browser.
|
||||
assert_eq!(split(r"C:\Help\manual.pdf#intro", &manual), Some((r"C:\Help\manual.pdf".into(), named("intro"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_parameters_apply_in_turn_and_unknown_ones_are_ignored() {
|
||||
let pdf = files(&["/docs/a.pdf"]);
|
||||
assert_eq!(split("/docs/a.pdf#page=3&zoom=200,0,0", &pdf), Some(("/docs/a.pdf".into(), page(2))));
|
||||
assert_eq!(split("/docs/a.pdf#zoom=50#nameddest=x", &pdf), Some(("/docs/a.pdf".into(), named("x"))));
|
||||
assert_eq!(split("/docs/a.pdf#nameddest=x&page=4", &pdf), Some(("/docs/a.pdf".into(), page(3))));
|
||||
assert_eq!(split("/docs/a.pdf#PAGE=2", &pdf), Some(("/docs/a.pdf".into(), page(1))));
|
||||
// The file opens at its default view when nothing can be applied.
|
||||
for odd in
|
||||
["/docs/a.pdf#", "/docs/a.pdf#page=0", "/docs/a.pdf#page=two", "/docs/a.pdf#page=-1", "/docs/a.pdf#nameddest=", "/docs/a.pdf#view=Fit"]
|
||||
{
|
||||
assert_eq!(split(odd, &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)), "{odd}");
|
||||
}
|
||||
assert_eq!(split("/docs/a.pdf#page=99999999999999999999999", &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_a_file_is_left_alone() {
|
||||
// A file whose name has a `#` in it, alone and with parameters of its own.
|
||||
let hashed = files(&["/docs/C#.pdf", "/docs/#1/a.pdf"]);
|
||||
assert_eq!(split("/docs/C#.pdf", &hashed), None);
|
||||
assert_eq!(split("/docs/C#.pdf#page=2", &hashed), Some(("/docs/C#.pdf".into(), page(1))));
|
||||
assert_eq!(split("/docs/#1/a.pdf#page=2", &hashed), Some(("/docs/#1/a.pdf".into(), page(1))));
|
||||
// Nothing before a `#` is a file: the open fails on the path as given.
|
||||
assert_eq!(split("/docs/missing.pdf#page=2", &hashed), None);
|
||||
assert_eq!(split("/docs/plain.pdf", &hashed), None);
|
||||
assert_eq!(split("#", &hashed), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! The print-ready PDF as the system spooler gets it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_full};
|
||||
|
||||
use crate::guard;
|
||||
|
||||
/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler
|
||||
/// with `job`. Two things happen on Windows only; elsewhere the bytes go as they are.
|
||||
///
|
||||
/// - A job in grayscale is made grey here first ([`greyed`]), and `job.grey_pages` says so.
|
||||
/// Windows has no colour conversion of its own, and a driver that lists no grey option (Microsoft
|
||||
/// Print to PDF, or one with only a vendor's own grey setting) refused the job outright
|
||||
/// (#5806). The driver is still asked for grey where it has it.
|
||||
/// - Large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131): Windows'
|
||||
/// PDF renderer prints an image of more than 2²² pixels from a shrunk copy, so a scan comes out
|
||||
/// at the printer's resolution.
|
||||
///
|
||||
/// When the copy can't be read or written, the bytes go as they are: neither step ever stops a
|
||||
/// print, and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey,
|
||||
/// strictly, as before.
|
||||
pub fn spooler_copy(bytes: Vec<u8>, job: &mut crate::print::spool::Job) -> Vec<u8> {
|
||||
if !cfg!(windows) {
|
||||
return bytes;
|
||||
}
|
||||
let mut bytes = Arc::new(bytes);
|
||||
if job.grayscale {
|
||||
match greyed(bytes.clone()) {
|
||||
Some(Some(grey)) => {
|
||||
bytes = Arc::new(grey);
|
||||
job.grey_pages = true;
|
||||
}
|
||||
Some(None) => job.grey_pages = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Once `tiled` returns, its document is gone and `bytes` is the only owner left: no copy.
|
||||
tiled(bytes.clone()).unwrap_or_else(|| Arc::try_unwrap(bytes).unwrap_or_else(|shared| (*shared).clone()))
|
||||
}
|
||||
|
||||
/// `bytes` with every colour on every page, its comments and form fields included, turned grey by
|
||||
/// Convert colours (black kept black, pictures re-encoded where they can be): `Some(Some(_))`.
|
||||
/// `Some(None)` when nothing needed converting, and `None` when the copy can't be read or written.
|
||||
///
|
||||
/// Convert colours leaves shadings, patterns, inline images and pictures it can't re-encode
|
||||
/// (indexed, CMYK, JPEG 2000 …) as they are; the driver is still asked for grey, so a driver with a
|
||||
/// grey option makes those grey too, and one without prints them as they are.
|
||||
pub fn greyed(bytes: Arc<Vec<u8>>) -> Option<Option<Vec<u8>>> {
|
||||
guard(|| {
|
||||
let mut doc = pdfcraft_cos::Document::open(bytes).ok()?;
|
||||
let pages: Vec<usize> = (0..pdfcraft_model::pages(&doc).len()).collect();
|
||||
let settings =
|
||||
pdfcraft_edit::ConvertColours { target: pdfcraft_edit::ColourTarget::Gray, preserve_black: true, images: true, annotations: true };
|
||||
let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, _: pdfcraft_edit::ColourTarget| {
|
||||
pdfcraft_optimize::recolour_image(doc, s, pdfcraft_optimize::ImageTarget::Gray)
|
||||
};
|
||||
let done = pdfcraft_edit::convert_colours(&mut doc, &pages, &settings, &mut recolour).ok()?;
|
||||
if done.colours == 0 && done.images == 0 {
|
||||
return Some(None);
|
||||
}
|
||||
write_full(&doc, &SaveOptions::default()).ok().map(Some)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// `bytes` with their large images drawn as tiles, or `None` when there is none, or the copy
|
||||
/// can't be read or written.
|
||||
pub fn tiled(bytes: Arc<Vec<u8>>) -> Option<Vec<u8>> {
|
||||
guard(|| {
|
||||
let mut doc = pdfcraft_cos::Document::open(bytes).ok()?;
|
||||
let report = pdfcraft_optimize::tile_large_images(&mut doc);
|
||||
(report.tiled > 0).then(|| write_full(&doc, &SaveOptions::default()).ok()).flatten()
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
|
||||
use super::*;
|
||||
use crate::print::spool::Job;
|
||||
|
||||
/// A one-page PDF drawing a `w` × `h` grey image over the page.
|
||||
fn page_with_image(w: i64, h: i64) -> Vec<u8> {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||||
let mut image = Dict::new();
|
||||
image.set("Type", Object::name("XObject"));
|
||||
image.set("Subtype", Object::name("Image"));
|
||||
image.set("Width", Object::Int(w));
|
||||
image.set("Height", Object::Int(h));
|
||||
image.set("ColorSpace", Object::name("DeviceGray"));
|
||||
image.set("BitsPerComponent", Object::Int(8));
|
||||
let samples: Vec<u8> = (0..w * h).map(|i| (i % 251) as u8).collect();
|
||||
let image = doc.add(Object::Stream(Stream::flate(image, &samples)));
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set("Im", Object::Ref(image));
|
||||
let mut resources = Dict::new();
|
||||
resources.set("XObject", Object::Dict(xobjects));
|
||||
let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 612 0 0 792 0 0 cm /Im Do Q".to_vec())));
|
||||
let mut page = Dict::new();
|
||||
page.set("Type", Object::name("Page"));
|
||||
page.set("Parent", Object::Ref(pages));
|
||||
page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()));
|
||||
page.set("Resources", Object::Dict(resources));
|
||||
page.set("Contents", Object::Ref(content));
|
||||
let page = doc.add(Object::Dict(page));
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set("Count", Object::Int(1));
|
||||
d.set("Kids", Object::Array(vec![Object::Ref(page)]));
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_image_goes_to_the_spooler_as_tiles() {
|
||||
let tiled = tiled(Arc::new(page_with_image(2100, 2100))).expect("tiled");
|
||||
let doc = Document::open(Arc::new(tiled)).unwrap();
|
||||
let largest = doc
|
||||
.scan_objects()
|
||||
.filter_map(|(_, o)| match &*o {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"Width")? * s.dict.int(b"Height")?),
|
||||
_ => None,
|
||||
})
|
||||
.max();
|
||||
// The page's image is a form now; its tiles are the images it draws.
|
||||
assert_eq!(largest, Some(2001 * 2001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_with_nothing_to_tile_or_unreadable_goes_as_it_is() {
|
||||
assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None);
|
||||
assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None);
|
||||
let mut job = Job::default();
|
||||
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job), b"not a PDF");
|
||||
let small = page_with_image(100, 100);
|
||||
assert_eq!(spooler_copy(small.clone(), &mut job), small);
|
||||
assert!(!job.grey_pages);
|
||||
// A grayscale job whose copy can't be read asks the driver for grey as before.
|
||||
let mut grey = Job { grayscale: true, ..Job::default() };
|
||||
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey), b"not a PDF");
|
||||
assert!(!grey.grey_pages);
|
||||
}
|
||||
|
||||
/// A one-page PDF filling the page in red.
|
||||
fn red_page() -> Vec<u8> {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||||
let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1 0 0 rg 0 0 612 792 re f".to_vec())));
|
||||
let mut page = Dict::new();
|
||||
page.set("Type", Object::name("Page"));
|
||||
page.set("Parent", Object::Ref(pages));
|
||||
page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()));
|
||||
page.set("Contents", Object::Ref(content));
|
||||
let page = doc.add(Object::Dict(page));
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set("Count", Object::Int(1));
|
||||
d.set("Kids", Object::Array(vec![Object::Ref(page)]));
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// The operators of the first page's content.
|
||||
fn first_page_content(bytes: Vec<u8>) -> String {
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let contents = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap();
|
||||
match &*doc.get(contents) {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #5806: a page in colour is made grey for a grayscale job, which says so.
|
||||
#[test]
|
||||
fn a_colour_page_is_made_grey_for_a_grayscale_job() {
|
||||
let grey = greyed(Arc::new(red_page())).expect("readable").expect("converted");
|
||||
let content = first_page_content(grey.clone());
|
||||
assert!(!content.contains(" rg"), "{content}");
|
||||
assert!(content.contains(" g"), "{content}");
|
||||
// Already grey: nothing to convert, and the pages are still grey.
|
||||
assert_eq!(greyed(Arc::new(grey)), Some(None));
|
||||
assert_eq!(greyed(Arc::new(b"not a PDF".to_vec())), None);
|
||||
}
|
||||
|
||||
/// On Windows a grayscale job's copy is grey and the job says its pages are; elsewhere the
|
||||
/// bytes go as they are and the driver is asked, as before. A colour job is never converted.
|
||||
#[test]
|
||||
fn a_grayscale_job_sends_grey_pages_on_windows_only() {
|
||||
let red = red_page();
|
||||
let mut job = Job { grayscale: true, ..Job::default() };
|
||||
let sent = spooler_copy(red.clone(), &mut job);
|
||||
if cfg!(windows) {
|
||||
assert!(job.grey_pages);
|
||||
assert!(!first_page_content(sent).contains(" rg"));
|
||||
} else {
|
||||
assert!(!job.grey_pages);
|
||||
assert_eq!(sent, red);
|
||||
}
|
||||
let mut colour = Job::default();
|
||||
assert_eq!(spooler_copy(red.clone(), &mut colour), red);
|
||||
assert!(!colour.grey_pages);
|
||||
}
|
||||
|
||||
/// On Windows the spooler gets the tiled copy ([`tiled`]); elsewhere the bytes as they are.
|
||||
#[test]
|
||||
fn the_spooler_gets_tiles_on_windows_only() {
|
||||
let large = page_with_image(2100, 2100);
|
||||
let sent = spooler_copy(large.clone(), &mut Job::default());
|
||||
if cfg!(windows) {
|
||||
assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref());
|
||||
assert_ne!(sent, large);
|
||||
} else {
|
||||
assert_eq!(sent, large);
|
||||
}
|
||||
}
|
||||
}
|
||||
+447
-1
@@ -6632,6 +6632,226 @@ fn form_javascript_validates_calculates_and_formats() {
|
||||
assert!(s.run_javascript(id, "1", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_now_updates_live_fields_in_one_undo_step() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("calculate-now.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
let out = s
|
||||
.run_javascript(
|
||||
id,
|
||||
"getField('price').value = 3; getField('qty').value = 2; calculateNow(); var seen = getField('total').value; console.println(seen);",
|
||||
Some("go"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.console.iter().any(|line| line == "6"), "next statement sees calculation result: {out:?}");
|
||||
let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone();
|
||||
assert_eq!(total, ["6"]);
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript"));
|
||||
s.undo(id).unwrap();
|
||||
assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.is_empty());
|
||||
s.redo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]);
|
||||
let bytes = s.save_bytes(id).unwrap();
|
||||
let reopened = s.open("reopened.pdf", None, bytes, None).unwrap();
|
||||
assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_now_applies_format_script_to_updated_appearance() {
|
||||
let mut session = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = session.open("calculate-format.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
session.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; calculateNow();", None).unwrap();
|
||||
|
||||
assert_eq!(session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value, ["6"]);
|
||||
assert!(page_texts(&session, id)[0].contains("USD 6.00"), "the appearance uses the Format script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculation_scripts_are_built_only_for_scripts_that_call_calculate_now() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("calculate-list.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0));
|
||||
|
||||
s.run_javascript(id, "1 + 1", None).unwrap();
|
||||
assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0);
|
||||
|
||||
s.run_javascript(id, "calculateNow();", None).unwrap();
|
||||
assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1);
|
||||
|
||||
use pdfcraft_forms::{FieldEvent, Scripts};
|
||||
let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap();
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let target = fields.iter().find(|f| f.name == "go").unwrap();
|
||||
let mut runner = crate::js::JsRunner::new(&cos, "calculate-list.pdf", false);
|
||||
crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0));
|
||||
|
||||
runner.run(FieldEvent::Calculate, "1 + 1", target, "", &fields);
|
||||
assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0);
|
||||
|
||||
runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields);
|
||||
assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1);
|
||||
runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields);
|
||||
assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_now_in_a_document_script_helper_gets_the_calculation_list() {
|
||||
let helpers = "function times\\(a, b\\) { return a * b; } function recalc\\(\\) { calculateNow\\(\\); }";
|
||||
let mut session = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = session.open("calculate-helper.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap();
|
||||
let out = session
|
||||
.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; recalc(); console.println(getField('total').value);", None)
|
||||
.unwrap();
|
||||
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert!(out.console.iter().any(|line| line == "6"), "the document helper recalculated total: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_now_uses_calculation_order_and_bounds_recursion() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("calculate-chain.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
for (name, script) in
|
||||
[("qty", "event.value = Number(getField('price').value) + 1;"), ("total", "event.value = Number(getField('qty').value) * 2;")]
|
||||
{
|
||||
s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap();
|
||||
}
|
||||
s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] }).unwrap();
|
||||
let out = s.run_javascript(id, "getField('price').value = 3; calculateNow(); console.println(getField('total').value);", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert!(out.console.iter().any(|line| line == "8"), "{out:?}");
|
||||
|
||||
for (name, script) in
|
||||
[("qty", "event.value = Number(getField('total').value) + 1;"), ("total", "event.value = Number(getField('qty').value) + 1;")]
|
||||
{
|
||||
s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap();
|
||||
}
|
||||
let cyclic = s.run_javascript(id, "calculateNow();", None).unwrap();
|
||||
assert!(cyclic.error.is_none(), "a cyclic calculation runs once in order: {cyclic:?}");
|
||||
|
||||
s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) })
|
||||
.unwrap();
|
||||
let out = s.run_javascript(id, "calculateNow();", None).unwrap();
|
||||
assert!(out.console.iter().any(|line| line.contains("calculateNow recursion limit exceeded")), "{out:?}");
|
||||
s.set_javascript(false);
|
||||
assert!(s.run_javascript(id, "calculateNow();", None).is_err());
|
||||
s.set_javascript(true);
|
||||
assert!(s.run_javascript(id, "1 + 1", None).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculation_order_cache_tracks_script_edits_in_a_batch() {
|
||||
let mut session = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = session.open("calculate-batch.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::Batch {
|
||||
label: "Set calculations".into(),
|
||||
edits: vec![
|
||||
Edit::SetFieldScript {
|
||||
name: "qty".into(),
|
||||
event: "calculate".into(),
|
||||
script: Some("event.value = Number(getField('price').value) + 1;".into()),
|
||||
},
|
||||
Edit::SetFieldScript {
|
||||
name: "total".into(),
|
||||
event: "calculate".into(),
|
||||
script: Some("event.value = Number(getField('qty').value) * 2;".into()),
|
||||
},
|
||||
Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] },
|
||||
Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("3".into()) },
|
||||
],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let total = session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value.clone();
|
||||
assert_eq!(total, ["8"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculation_order_cache_tracks_field_property_edits_in_a_batch() {
|
||||
use form_scripts::{CalcOp, Calculate};
|
||||
|
||||
let mut session = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = session.open("calculate-props-batch.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::Batch {
|
||||
label: "Change calculation".into(),
|
||||
edits: vec![
|
||||
Edit::SetFieldScript {
|
||||
name: "qty".into(),
|
||||
event: "format".into(),
|
||||
script: Some("calculateNow(); console.println(getField('total').value);".into()),
|
||||
},
|
||||
Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) },
|
||||
Edit::SetFieldProps {
|
||||
name: "total".into(),
|
||||
props: Box::new(FieldProps {
|
||||
calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into()] }),
|
||||
..FieldProps::default()
|
||||
}),
|
||||
},
|
||||
Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) },
|
||||
],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let total = session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value.clone();
|
||||
assert_eq!(total, ["4"]);
|
||||
let output = session.take_js_output(id);
|
||||
assert!(output.console.iter().any(|line| line == "4"), "calculateNow observed the refreshed Sum: {output:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculation_order_cache_refreshes_after_inserting_pages() {
|
||||
use pdfcraft_forms::{FieldEvent, Scripts};
|
||||
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap();
|
||||
pdfcraft_forms::set_value(&mut doc, "qty", &FieldValue::Text("3".into())).unwrap();
|
||||
let fields = pdfcraft_forms::fields(&doc);
|
||||
let qty = fields.iter().find(|field| field.name == "qty").unwrap();
|
||||
let mut js = crate::js::JsRunner::new(&doc, "destination.pdf", false);
|
||||
js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields);
|
||||
|
||||
let source = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /CO [4 0 R] >> >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R] >>")
|
||||
.obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (importedTotal) /Rect [20 200 180 220] /V (0) /AA << /C << /S /JavaScript /JS (event.value = 9; console.println('inserted field calculated');) >> >> >>")
|
||||
.build();
|
||||
let mut cx = EditCtx::new(None, 0);
|
||||
cx.js = Some(js);
|
||||
run_edit(
|
||||
&mut doc,
|
||||
&Edit::InsertPagesFrom {
|
||||
name: "source.pdf".into(),
|
||||
bytes: Arc::new(source),
|
||||
pages: None,
|
||||
at: 1,
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
|
||||
},
|
||||
&mut cx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fields = pdfcraft_forms::fields(&doc);
|
||||
let qty = fields.iter().find(|field| field.name == "qty").unwrap();
|
||||
let js = cx.js.as_mut().unwrap();
|
||||
let result = js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields);
|
||||
assert!(
|
||||
result.changes.iter().any(|change| {
|
||||
change.name == "importedTotal" && change.value.as_ref().is_some_and(|value| value.len() == 1 && value.first().is_some_and(|v| v == "9"))
|
||||
}),
|
||||
"imported calculation result: {:?}",
|
||||
result.changes
|
||||
);
|
||||
assert!(js.output.console.iter().any(|line| line == "inserted field calculated"), "{:#?}", js.output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javascript_field_rect_moves_first_widget_and_undoes() {
|
||||
let mut s = Session::new();
|
||||
@@ -6753,6 +6973,109 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_get_page_box_reads_effective_boxes_without_editing_the_document() {
|
||||
let (mut s, id) = session_with(1);
|
||||
let before = s.get(id).expect("opened document").can_undo().map(str::to_owned);
|
||||
let out = s
|
||||
.run_javascript(id, "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 0), this.getPageBox('Trim', 0)])", None)
|
||||
.expect("script runs");
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("[[0,300,200,0],[0,300,200,0],[0,300,200,0]]"));
|
||||
assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref());
|
||||
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotate page");
|
||||
let before = s.get(id).expect("rotated document").can_undo().map(str::to_owned);
|
||||
let out = s.run_javascript(id, "JSON.stringify(this.getPageBox('Crop', 0))", None).expect("script runs");
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("[0,200,300,0]"));
|
||||
assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_field_scripts_cannot_print() {
|
||||
use pdfcraft_forms::{FieldEvent, Scripts};
|
||||
let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap();
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let target = fields.iter().find(|f| f.name == "qty").unwrap();
|
||||
for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] {
|
||||
let mut runner = js::JsRunner::new(&cos, "order.pdf", false);
|
||||
let result = runner.run(
|
||||
event,
|
||||
"console.println('before'); this.print(); app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment'); \
|
||||
this.pageNum = 0; this.print(); app.alert('ok'); event.value = '4';",
|
||||
target,
|
||||
"2",
|
||||
&fields,
|
||||
);
|
||||
assert!(result.rc, "{event:?}: {result:?}");
|
||||
assert_eq!(result.value, "4");
|
||||
assert_eq!(runner.output.requests, [js::Request::GoToPage(0)], "{event:?}: {:?}", runner.output);
|
||||
assert_eq!(runner.output.alerts, [js::Message::plain("ok")]);
|
||||
assert!(runner.output.console.contains(&"before".to_string()));
|
||||
assert!(runner.output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{:?}", runner.output);
|
||||
assert_eq!(runner.output.console.iter().filter(|line| line.contains("only a button may")).count(), 2, "{:?}", runner.output);
|
||||
assert!(runner.output.errors.is_empty());
|
||||
|
||||
runner.output = js::JsOutput::default();
|
||||
runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields);
|
||||
assert!(runner.output.requests.is_empty(), "{event:?}: {:?}", runner.output);
|
||||
assert!(runner.output.errors.iter().any(|e| e.contains("broken script")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_print_suppression_preserves_document_errors() {
|
||||
use pdfcraft_forms::{FieldEvent, Scripts};
|
||||
let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap();
|
||||
pdfcraft_forms::set_document_script(&mut cos, "failure", Some("throw new Error('document failure');")).unwrap();
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let target = fields.iter().find(|f| f.name == "qty").unwrap();
|
||||
let mut runner = js::JsRunner::new(&cos, "order.pdf", false);
|
||||
for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] {
|
||||
runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields);
|
||||
}
|
||||
let out = runner.output;
|
||||
assert!(out.requests.is_empty(), "{out:?}");
|
||||
assert_eq!(out.console.iter().filter(|line| line.contains("Print") && line.contains("ignored")).count(), 4, "{out:?}");
|
||||
assert_eq!(out.doc_errors.len(), 1, "the document script's error is reported once: {out:?}");
|
||||
assert!(out.doc_errors[0].contains("document failure"), "{out:?}");
|
||||
assert_eq!(out.errors.iter().filter(|e| e.contains("document failure")).count(), 1, "{out:?}");
|
||||
assert_eq!(out.errors.iter().filter(|e| e.contains("field failure")).count(), 4, "each field script's error is retained: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_calculations_cannot_print_but_buttons_and_console_can() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
s.apply(
|
||||
id,
|
||||
Edit::SetFieldScript {
|
||||
name: "total".into(),
|
||||
event: "calculate".into(),
|
||||
script: Some("this.print(); event.value = times(getField('price').value, getField('qty').value);".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.requests.is_empty(), "{out:?}");
|
||||
assert!(out.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{out:?}");
|
||||
assert!(out.errors.is_empty(), "{out:?}");
|
||||
assert!(page_texts(&s, id)[0].contains("USD 10.00"));
|
||||
|
||||
for (target, qty, formatted) in [(Some("go"), 6, "USD 15.00"), (None, 8, "USD 20.00")] {
|
||||
let out = s.run_javascript(id, &format!("getField('qty').value = {qty}; this.print();"), target).unwrap();
|
||||
assert_eq!(out.requests, [js::Request::Print]);
|
||||
let automatic = s.take_js_output(id);
|
||||
assert!(automatic.requests.is_empty(), "{automatic:?}");
|
||||
assert!(automatic.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{automatic:?}");
|
||||
assert!(automatic.errors.is_empty(), "{automatic:?}");
|
||||
assert!(page_texts(&s, id)[0].contains(formatted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_history_does_not_replay_field_script_side_effects() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
@@ -8550,6 +8873,20 @@ fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() {
|
||||
s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now");
|
||||
}
|
||||
|
||||
/// A laid-out XFA form tabs the way its template's traversal says: the probe form, which
|
||||
/// another viewer tabs `start A2 A3 A4 A1 B1 B3 B2 B4 C3 D1 D4 D6 finish C1 C2`.
|
||||
#[test]
|
||||
fn xfa_forms_tab_in_their_templates_traversal_order() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::traversal_template()));
|
||||
let mut s = Session::new();
|
||||
let id = s.open("tab.pdf", None, bytes, None).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut tabs: Vec<(usize, &str)> = doc.form.iter().flat_map(|f| f.widgets.iter().map(move |w| (w.tab, f.name.as_str()))).collect();
|
||||
tabs.sort_unstable();
|
||||
let names: Vec<&str> = tabs.iter().map(|t| t.1).filter(|n| !["log", "D5"].contains(n)).collect();
|
||||
assert_eq!(names, ["start", "A2", "A3", "A4", "A1", "B1", "B3", "B2", "B4", "C3", "D1", "D4", "D6", "finish", "C1", "C2"]);
|
||||
}
|
||||
|
||||
/// A scripted form end to end: initialize and calculate on open, recalculation after a change,
|
||||
/// a failing validation's message, buttons that add and remove rows and show messages, a check
|
||||
/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all.
|
||||
@@ -11338,6 +11675,61 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() {
|
||||
assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}");
|
||||
}
|
||||
|
||||
/// `run_javascript` runs an XFA click script only for a push button, as the app does: a text
|
||||
/// field with a click event of its own, named as the target, asks to open no attached file.
|
||||
#[test]
|
||||
fn run_javascript_runs_xfa_click_scripts_only_for_buttons() {
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<field name="guide" w="2in" h="0.3in"><ui><button/></ui><caption><value><text>Instructions</text></value></caption>
|
||||
<event activity="click"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
<field name="note" w="2in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
s.take_js_output(id);
|
||||
let o = s.run_javascript(id, "", Some("guide")).unwrap();
|
||||
assert_eq!(o.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "the button's click: {o:?}");
|
||||
let o = s.run_javascript(id, "", Some("note")).unwrap();
|
||||
assert!(o.requests.is_empty(), "a text field's click script doesn't run here: {o:?}");
|
||||
}
|
||||
|
||||
/// In an XFA form a button's click script may open an attached file; the same request from a
|
||||
/// text field's exit script is noted in the console and not made.
|
||||
#[test]
|
||||
fn an_xfa_button_may_open_an_attached_file_and_other_events_may_not() {
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<field name="guide" w="2in" h="0.3in"><ui><button/></ui><caption><value><text>Instructions</text></value></caption>
|
||||
<event activity="click"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
<field name="note" w="2in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="exit"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::XfaEvent { som: "form[0].guide[0]".into(), activity: "click".into() }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert_eq!(out.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "{out:?}");
|
||||
s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.requests.is_empty(), "only a click opens a file: {out:?}");
|
||||
assert!(out.console.iter().any(|c| c.contains("open an attached file")), "{:?}", out.console);
|
||||
}
|
||||
|
||||
/// A field whose layout:ready script only reads the page count keeps the value its script gives
|
||||
/// it: only a field the script gives the page call's result is numbered by the layout.
|
||||
#[test]
|
||||
@@ -13237,10 +13629,35 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() {
|
||||
assert_eq!(globals(&doc, images[2]), None, "the DeviceN page is coded alone");
|
||||
pdfcraft_redact::apply(&mut doc, None).unwrap();
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if saved.jbig2_globals_ref(s).is_some())).count();
|
||||
// Optimize writes the parameters direct, so a stream that names the dictionary holds the key.
|
||||
let names = |s: &Stream| {
|
||||
let mut bytes = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut bytes);
|
||||
bytes.windows(12).any(|w| w == b"JBIG2Globals")
|
||||
};
|
||||
let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if names(s))).count();
|
||||
assert_eq!(named, 0, "no image names the dictionary, which left with them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let original = s.get(id).unwrap().added()[0].obj;
|
||||
let target = [10.0, 10.0, 60.0, 60.0];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap();
|
||||
let added = s.get(id).unwrap().added();
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(added[0].obj, original);
|
||||
assert_eq!(added[0].content.rect(), target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() {
|
||||
let (mut session, id) = rotated_blank(0);
|
||||
@@ -13404,3 +13821,32 @@ fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_passw
|
||||
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
|
||||
assert!(reopened.embedded_files_locked(), "the attachment stays encrypted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_native_path_does() {
|
||||
// amount (4), group b (5) with kids b.c (6) and b.d (7), total (8) = SUM(amount, b).
|
||||
let widget = |rest: &str, y: u32| format!("<< /Type /Annot /Subtype /Widget /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {rest} >>", y + 20);
|
||||
let pdf = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 8 0 R] /CO [8 0 R] /DA (/Helv 0 Tf 0 g) >> >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R 8 0 R] >>")
|
||||
.obj(widget("/FT /Tx /T (amount) /V (1,234.50)", 250))
|
||||
.obj("<< /FT /Tx /T (b) /Kids [6 0 R 7 0 R] >>")
|
||||
.obj(widget("/Parent 5 0 R /T (c) /V ($10)", 220))
|
||||
.obj(widget("/Parent 5 0 R /T (d) /V (1.234,5)", 190))
|
||||
.obj(widget(
|
||||
"/FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"amount\", \"b\"\\)\\);) >> >>",
|
||||
160,
|
||||
))
|
||||
.build();
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("simple.pdf", None, Arc::new(pdf), None).unwrap();
|
||||
let total = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone();
|
||||
// The commit path (native Simple calculation) on an edit elsewhere.
|
||||
s.apply(id, Edit::SetFieldValue { name: "b.c".into(), value: FieldValue::Text("$10".into()) }).unwrap();
|
||||
assert_eq!(total(&s), ["2479"], "native Simple calculation");
|
||||
let out = s.run_javascript(id, "getField('amount').value = '2,000.25'; calculateNow(); console.println(getField('total').value);", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert!(out.console.iter().any(|line| line == "3244.75"), "calculateNow agrees with the native path: {out:?}");
|
||||
assert_eq!(total(&s), ["3244.75"]);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> {
|
||||
XfaEffect::Print => Some("print"),
|
||||
XfaEffect::SaveAs => Some("save"),
|
||||
XfaEffect::LaunchUrl(_) => Some("open a link"),
|
||||
XfaEffect::OpenAttachment { .. } => Some("open an attached file"),
|
||||
XfaEffect::ShowPanel(_) => Some("show a side panel"),
|
||||
XfaEffect::SetFocus(_) => Some("move the focus"),
|
||||
_ => None,
|
||||
@@ -910,6 +911,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
XfaEffect::Print => self.run.out.requests.push(Request::Print),
|
||||
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
|
||||
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
|
||||
XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }),
|
||||
XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)),
|
||||
XfaEffect::SetFocus(som) => {
|
||||
if let Some(name) = self.by_som().get(som).cloned() {
|
||||
|
||||
@@ -15,13 +15,10 @@ pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout,
|
||||
/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the
|
||||
/// picture its signer's application drew.
|
||||
pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> {
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) {
|
||||
continue;
|
||||
}
|
||||
pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
|
||||
}
|
||||
Ok(())
|
||||
let signed = |doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field| {
|
||||
f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V"))
|
||||
};
|
||||
pdfcraft_forms::redraw_fields(doc, |doc, f| !signed(doc, f)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font
|
||||
|
||||
@@ -21,7 +21,7 @@ fn names_lock() -> MutexGuard<'static, ()> {
|
||||
|
||||
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
|
||||
fn printd(fmt: &str, typed: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
@@ -75,7 +75,7 @@ fn form_dates_follow_the_date_language() {
|
||||
#[test]
|
||||
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
let _names = names_lock();
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
|
||||
let mut bad = Vec::new();
|
||||
for language in ["it", "es", "ro", "sq"] {
|
||||
@@ -111,7 +111,7 @@ fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
/// letter that writes a name, through the form path and through `util.scand`. Called from the
|
||||
/// one test, which owns the process-wide names.
|
||||
fn every_date_language_reads_back_what_it_writes() {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let mut bad = Vec::new();
|
||||
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
|
||||
use_form_date_language(l.code);
|
||||
@@ -319,7 +319,7 @@ fn an_english_run_that_prints_the_stored_value_is_drawn() {
|
||||
|
||||
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
|
||||
fn scanned(fmt: &str, text: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
|
||||
@@ -419,18 +419,21 @@ fn a_formatting_change_redraws_the_whole_ocr_phrase() {
|
||||
assert_eq!(drawn.join(" "), "John Smyth Example");
|
||||
}
|
||||
|
||||
/// A layer whose words follow one another with no position of their own: a word that went would
|
||||
/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead.
|
||||
/// A layer whose words follow one another with no position of their own: a word that goes leaves
|
||||
/// its advance behind (ISO 32000-2 §9.4.4), so the words after it stay where they were and are
|
||||
/// kept as the scan, with nothing drawn.
|
||||
#[test]
|
||||
fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() {
|
||||
fn words_that_depend_on_the_one_before_are_kept_in_place() {
|
||||
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET");
|
||||
let mut s = Session::new();
|
||||
let id = s.open("relative.pdf", None, bytes, None).unwrap();
|
||||
assert_eq!(texts(&s, id), ["John Smith Example"]);
|
||||
let right = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Example")).map(|l| l.rect[2]);
|
||||
let before = right(&s).unwrap();
|
||||
edit_block(&mut s, id, 0, "John Example");
|
||||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||||
assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}");
|
||||
assert_eq!(drawn, ["John Example"]);
|
||||
assert_eq!((scan, drawn), (vec!["John".to_owned(), "Example".to_owned()], Vec::<String>::new()));
|
||||
assert_eq!(right(&s), Some(before), "Example stays where it was");
|
||||
}
|
||||
|
||||
/// The edit survives a save and reopen, and each piece can be edited again.
|
||||
@@ -460,19 +463,21 @@ fn a_draft_with_the_same_words_changes_nothing() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible text positioned after the end of an OCR word depends on that word. An edit may not
|
||||
/// leave the word gone and the visible text somewhere else while calling the rest kept.
|
||||
/// Visible text positioned after the end of an OCR word depends on that word. The word that goes
|
||||
/// leaves its advance behind, so the visible text stays where it was and the word kept stays the
|
||||
/// scan's.
|
||||
#[test]
|
||||
fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() {
|
||||
fn visible_text_that_follows_a_removed_ocr_word_stays_in_place() {
|
||||
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET");
|
||||
let mut s = Session::new();
|
||||
let id = s.open("label.pdf", None, bytes, None).unwrap();
|
||||
let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]);
|
||||
assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]);
|
||||
assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Label".to_owned()]));
|
||||
let before = label(&s).unwrap();
|
||||
edit_block(&mut s, id, 0, "John");
|
||||
let (scan, _) = scan_and_drawn(&s, id);
|
||||
assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s));
|
||||
// Nothing new is drawn: the visible text is the page's own.
|
||||
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned()], vec!["Label".to_owned()]));
|
||||
assert_eq!(label(&s), Some(before), "Label stays where it was");
|
||||
}
|
||||
|
||||
/// A large word right before a small one, as OCR's sizes can make them: the large word's cover,
|
||||
|
||||
+130
-43
@@ -1,5 +1,5 @@
|
||||
use super::as_docx::{hex, rgb8};
|
||||
use super::layout::{Item, Page, items};
|
||||
use super::layout::{Item, Page, Run, Table, items};
|
||||
|
||||
/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab,
|
||||
/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them
|
||||
@@ -94,6 +94,64 @@ pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<&
|
||||
html_with_mode(pages, title, true, language)
|
||||
}
|
||||
|
||||
fn style_color(color: [f64; 3], simple: bool) -> String {
|
||||
if simple { String::new() } else { hex(color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }
|
||||
}
|
||||
|
||||
fn wrap_inline(mut text: String, bold: bool, italic: bool) -> String {
|
||||
if text.trim().is_empty() {
|
||||
return text;
|
||||
}
|
||||
if italic {
|
||||
text = format!("<em>{text}</em>");
|
||||
}
|
||||
if bold {
|
||||
text = format!("<strong>{text}</strong>");
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn para_html(runs: &[Run<'_>], lvl: u8, simple: bool) -> Option<String> {
|
||||
let non_empty: Vec<_> = runs.iter().map(|r| (r.block, esc(&r.text))).filter(|(_, text)| !text.is_empty()).collect();
|
||||
let &(first, _) = non_empty.iter().find(|(_, text)| !text.trim().is_empty())?;
|
||||
let mut t = String::new();
|
||||
for (block, text) in non_empty {
|
||||
if text.trim().is_empty() {
|
||||
t.push_str(&text);
|
||||
continue;
|
||||
}
|
||||
let mut run = wrap_inline(text, block.bold && lvl == 0, block.italic);
|
||||
// The paragraph has its first visible run's colour; a run in another colour says so.
|
||||
let colour = rgb8(block.color);
|
||||
if !simple && colour != rgb8(first.color) {
|
||||
let [red, green, blue] = colour;
|
||||
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
||||
}
|
||||
t.push_str(&run);
|
||||
}
|
||||
let style = style_color(first.color, simple);
|
||||
Some(match lvl.min(6) {
|
||||
0 => format!("<p{style}>{t}</p>\n"),
|
||||
l => format!("<h{l}{style}>{t}</h{l}>\n"),
|
||||
})
|
||||
}
|
||||
|
||||
fn table_html(t: &Table, simple: bool) -> String {
|
||||
let mut s = String::from("<table>\n");
|
||||
for row in &t.rows {
|
||||
s.push_str("<tr>");
|
||||
for c in row {
|
||||
let body = wrap_inline(esc(&c.text), c.bold, c.italic);
|
||||
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
|
||||
let style = style_color(c.color, simple);
|
||||
s.push_str(&format!("<td{span}{style}>{body}</td>"));
|
||||
}
|
||||
s.push_str("</tr>\n");
|
||||
}
|
||||
s.push_str("</table>\n");
|
||||
s
|
||||
}
|
||||
|
||||
fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String {
|
||||
let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default();
|
||||
let mut s = format!(
|
||||
@@ -108,50 +166,11 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s
|
||||
for it in items(pages) {
|
||||
match it {
|
||||
Item::Para(runs, lvl) => {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
let mut t = String::new();
|
||||
for r in &runs {
|
||||
let mut run = esc(&r.text);
|
||||
if r.block.italic {
|
||||
run = format!("<em>{run}</em>");
|
||||
}
|
||||
if r.block.bold && lvl == 0 {
|
||||
run = format!("<strong>{run}</strong>");
|
||||
}
|
||||
// The paragraph has its first run's colour; a run in another colour says so.
|
||||
let colour = rgb8(r.block.color);
|
||||
if !simple && colour != rgb8(first.color) {
|
||||
let [red, green, blue] = colour;
|
||||
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
||||
}
|
||||
t.push_str(&run);
|
||||
}
|
||||
let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
||||
match lvl {
|
||||
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
|
||||
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
|
||||
if let Some(p) = para_html(&runs, lvl, simple) {
|
||||
s.push_str(&p);
|
||||
}
|
||||
}
|
||||
Item::Table(t) => {
|
||||
s.push_str("<table>\n");
|
||||
for row in &t.rows {
|
||||
s.push_str("<tr>");
|
||||
for c in row {
|
||||
let mut body = esc(&c.text);
|
||||
if c.italic {
|
||||
body = format!("<em>{body}</em>");
|
||||
}
|
||||
if c.bold {
|
||||
body = format!("<strong>{body}</strong>");
|
||||
}
|
||||
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
|
||||
let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
||||
s.push_str(&format!("<td{span}{style}>{body}</td>"));
|
||||
}
|
||||
s.push_str("</tr>\n");
|
||||
}
|
||||
s.push_str("</table>\n");
|
||||
}
|
||||
Item::Table(t) => s.push_str(&table_html(&t, simple)),
|
||||
Item::Img(im) => {
|
||||
let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" };
|
||||
s.push_str(&format!("<p><img alt=\"\" src=\"data:{mime};base64,{}\"></p>\n", base64(&im.bytes)));
|
||||
@@ -162,3 +181,71 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s
|
||||
s.push_str("</body>\n</html>\n");
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{html, para_html, table_html};
|
||||
use crate::layout::{Block, Cell, Page, Run, Table};
|
||||
|
||||
fn block(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block {
|
||||
Block {
|
||||
text: text.into(),
|
||||
rect: [x0, y0, x1, y1],
|
||||
size: 12.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_control_only_runs_and_blocks_do_not_emit_empty_tags() {
|
||||
let page = Page {
|
||||
width: 612.0,
|
||||
height: 792.0,
|
||||
blocks: vec![
|
||||
Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block("\u{1}\u{FFFF}", 72.0, 700.0, 90.0, 712.0) },
|
||||
Block { color: [0.0, 0.5, 0.0], ..block("Hello", 90.5, 700.0, 140.0, 712.0) },
|
||||
Block { bold: true, ..block("\u{0}\u{2}", 72.0, 660.0, 120.0, 672.0) },
|
||||
],
|
||||
images: Vec::new(),
|
||||
background: None,
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let out = html(&[page], "Clean");
|
||||
assert!(out.contains("<p style=\"color:#008000\">Hello</p>"), "{out}");
|
||||
assert!(!out.contains("<strong></strong>"), "{out}");
|
||||
assert!(!out.contains("<em></em>"), "{out}");
|
||||
assert_eq!(out.matches("<p").count(), 1, "{out}");
|
||||
|
||||
// Styled whitespace-only paragraphs are omitted, and leading whitespace runs do not set
|
||||
// the paragraph colour or emit emphasis/colour wrappers.
|
||||
let ws_block = Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block(" ", 72.0, 700.0, 80.0, 712.0) };
|
||||
let vis_block = Block { color: [0.0, 0.5, 0.0], ..block("Visible", 80.0, 700.0, 120.0, 712.0) };
|
||||
assert_eq!(para_html(&[Run { text: " \t\n".into(), block: &ws_block }], 0, false), None);
|
||||
assert_eq!(
|
||||
para_html(&[Run { text: " ".into(), block: &ws_block }, Run { text: "Visible".into(), block: &vis_block }], 0, false).as_deref(),
|
||||
Some("<p style=\"color:#008000\"> Visible</p>\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_level_is_clamped_and_empty_styled_cells_stay_clean() {
|
||||
let b = block("Deep Heading", 72.0, 700.0, 200.0, 712.0);
|
||||
let runs = [Run { text: "Deep Heading".into(), block: &b }];
|
||||
assert_eq!(para_html(&runs, 9, true).as_deref(), Some("<h6>Deep Heading</h6>\n"));
|
||||
|
||||
let table = Table {
|
||||
rect: [0.0, 0.0, 100.0, 20.0],
|
||||
cols: vec![0.0, 50.0],
|
||||
rows: vec![vec![
|
||||
Cell { text: "\u{1}".into(), size: 11.0, bold: true, italic: true, color: [0.0; 3], span: 1 },
|
||||
Cell { text: "Val".into(), size: 11.0, bold: true, italic: false, color: [0.0; 3], span: 1 },
|
||||
]],
|
||||
ruled: None,
|
||||
};
|
||||
assert_eq!(table_html(&table, true), "<table>\n<tr><td></td><td><strong>Val</strong></td></tr>\n</table>\n");
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,18 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp
|
||||
run(chain, data, max_output, Some(max_output), false, false, None)
|
||||
}
|
||||
|
||||
/// [`decode_tolerant_counted`] with the two bounds apart: `max_stage` bounds every stage, as
|
||||
/// [`decode_tolerant`]'s bound does, and `max_output` the bytes all stages decode together. A
|
||||
/// stage stopped by either gives [`FilterError::LimitExceeded`] and the bytes decoded before it.
|
||||
pub fn decode_tolerant_counted_each(
|
||||
chain: &[(Filter, Params)],
|
||||
data: &[u8],
|
||||
max_stage: usize,
|
||||
max_output: usize,
|
||||
) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
|
||||
run(chain, data, max_stage, Some(max_output), false, false, None)
|
||||
}
|
||||
|
||||
/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images:
|
||||
/// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each
|
||||
/// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each
|
||||
@@ -451,6 +463,30 @@ mod tests {
|
||||
assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data));
|
||||
}
|
||||
|
||||
/// [`decode_tolerant_counted_each`] holds every stage to its own bound as well as the stages
|
||||
/// to their total: a Flate stage that would make more than that bound stops there, though
|
||||
/// the total has room, and the stage after it gets nothing; within both, the chain reads
|
||||
/// whole, one stage's output charged on top of another's.
|
||||
#[test]
|
||||
fn each_stage_is_held_to_its_own_bound_within_the_total() {
|
||||
let hex = "00".repeat(1 << 16).into_bytes();
|
||||
let data = super::flate::encode(&hex);
|
||||
let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())];
|
||||
// The total alone lets the Flate stage make all of the hex text, and the hex stage read it.
|
||||
STAGE_BYTES.with(|c| c.set(0));
|
||||
let whole = decode_tolerant_counted(&chain, &data, 3 << 16).map(|(v, _, _)| v);
|
||||
assert_eq!(whole.ok(), Some(vec![0u8; 1 << 16]));
|
||||
assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len() + hex.len());
|
||||
// Each stage held to 64 KiB, the Flate stage stops there and the hex stage gets nothing.
|
||||
STAGE_BYTES.with(|c| c.set(0));
|
||||
let held = decode_tolerant_counted_each(&chain, &data, 1 << 16, 3 << 16);
|
||||
assert!(matches!(held, Err((FilterError::LimitExceeded(m), 0)) if m == 1 << 16), "{held:?}");
|
||||
assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len());
|
||||
// Within both, it reads whole.
|
||||
let (out, _, spent) = decode_tolerant_counted_each(&chain, &data, 2 << 16, 3 << 16).unwrap();
|
||||
assert_eq!((out.len(), spent), (1 << 16, hex.len() + (1 << 16)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
+148
-35
@@ -14,7 +14,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream};
|
||||
|
||||
/// How the bytes of a string map to codes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -67,6 +67,11 @@ pub struct Metrics {
|
||||
/// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the
|
||||
/// embedded program. See [`cids_with_outlines`].
|
||||
outlined_cids: Vec<u64>,
|
||||
/// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`, a Type 3 glyph
|
||||
/// description) was left unread because the decode allowance [`Metrics::from_dict_within`]
|
||||
/// was given was spent: what it would have decided (code lengths, CIDs, widths, meanings, a
|
||||
/// Type 3 font's height) is a guess.
|
||||
pub past_allowance: bool,
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
@@ -79,6 +84,12 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option<Dict> {
|
||||
o.and_then(|o| doc.resolve(o).as_dict().cloned())
|
||||
}
|
||||
|
||||
/// Whether `font`'s `/Encoding` is the name Identity-H, given directly or through a reference,
|
||||
/// as [`Metrics::from_dict`] reads it (a Type 0 font's `/Encoding` may be indirect).
|
||||
fn identity_h(doc: &Document, font: &Dict) -> bool {
|
||||
font.get(b"Encoding").is_some_and(|e| doc.resolve(e).as_name() == Some(b"Identity-H".as_slice()))
|
||||
}
|
||||
|
||||
/// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in
|
||||
/// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…).
|
||||
fn legacy_indic_font(base_font: &str) -> bool {
|
||||
@@ -92,14 +103,17 @@ fn legacy_indic_font(base_font: &str) -> bool {
|
||||
/// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export
|
||||
/// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer.
|
||||
pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
|
||||
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0))
|
||||
// No total: one font's two programs, and the metrics read that may adapt one, are each
|
||||
// bounded by that stream's own limit.
|
||||
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0))
|
||||
}
|
||||
|
||||
/// The largest embedded sfnt program read for the editor.
|
||||
const MAX_SFNT: usize = 16 << 20;
|
||||
|
||||
/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares:
|
||||
/// each program decoded is charged whether or not it turns out to be usable.
|
||||
/// each program decoded is charged whether or not it turns out to be usable, and so are the
|
||||
/// streams of the metrics read that adapts a program without a Unicode cmap.
|
||||
pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref());
|
||||
@@ -108,7 +122,10 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue };
|
||||
// As in `Reads::decode`: each stage within the limit, the program, the last, too, read
|
||||
// through a view of the stream; once the allowance is spent a program with data is refused
|
||||
// before its parameters are resolved.
|
||||
let Ok(mut bytes) = budget.decode_stages_within(doc, stream, MAX_SFNT) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 {
|
||||
continue;
|
||||
@@ -120,13 +137,32 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) {
|
||||
return Some(bytes);
|
||||
}
|
||||
// A composite font's cmap is rebuilt only through Identity-H codes (`composite_glyph_maps`):
|
||||
// with any other `/Encoding` nothing is, so its metrics aren't read, and a program with a
|
||||
// cmap of its own is kept as it is, as when the rebuild finds nothing.
|
||||
if font.name(b"Subtype") == Some(b"Type0") && !identity_h(doc, font) {
|
||||
if own_cmap {
|
||||
return Some(bytes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only
|
||||
// cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged.
|
||||
let metrics = Metrics::from_dict(doc, font);
|
||||
// Metrics read past the allowance, its widths' read of the program again included,
|
||||
// adapt no copy.
|
||||
let metrics = Metrics::from_dict_within(doc, font, budget);
|
||||
if metrics.past_allowance {
|
||||
continue;
|
||||
}
|
||||
let mut mappings = std::collections::BTreeMap::new();
|
||||
if metrics.composite {
|
||||
let refused = budget.refused();
|
||||
if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) {
|
||||
mappings = map;
|
||||
} else if budget.refused() > refused {
|
||||
// The map the cmap is rebuilt from was left unread for want of the allowance: no
|
||||
// copy is listed, adapted or as it is, as for metrics read past it.
|
||||
continue;
|
||||
}
|
||||
if own_cmap {
|
||||
let charmap = face.charmap();
|
||||
@@ -166,7 +202,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
/// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has
|
||||
/// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read.
|
||||
/// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one.
|
||||
fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
fn cids_with_outlines(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<u64> {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref())
|
||||
else {
|
||||
@@ -175,13 +211,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() };
|
||||
let program = doc.resolve(&program);
|
||||
let Object::Stream(stream) = program.as_ref() else { return Vec::new() };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
|
||||
let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() };
|
||||
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start);
|
||||
match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() {
|
||||
None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))),
|
||||
Some(Object::Stream(map)) => {
|
||||
let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() };
|
||||
let Some(map) = reads.decode(map, MAX_CID_TO_GID) else { return Vec::new() };
|
||||
bit_set_below_64k(
|
||||
map.as_chunks::<2>()
|
||||
.0
|
||||
@@ -200,7 +236,7 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up
|
||||
/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself
|
||||
/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess.
|
||||
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
|
||||
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics, reads: &mut Reads<'_>) {
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _};
|
||||
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return };
|
||||
@@ -208,7 +244,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
if upem == 0.0 {
|
||||
@@ -354,8 +390,48 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20;
|
||||
/// thousand.
|
||||
const MAX_CMAP_RANGES: usize = 1 << 16;
|
||||
/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger
|
||||
/// one is not decoded at all.
|
||||
/// one is left unread (behind one filter not decoded past it; behind several, see `Reads::decode`).
|
||||
const MAX_CMAP_BYTES: usize = 16 << 20;
|
||||
/// Largest decoded `/CIDToGIDMap` stream read: two bytes for each CID below 65,536.
|
||||
const MAX_CID_TO_GID: usize = 1 << 17;
|
||||
|
||||
/// The streams one reading of a font decodes, against the caller's allowance, and whether one was
|
||||
/// left unread because that allowance was spent.
|
||||
struct Reads<'a> {
|
||||
doc: &'a Document,
|
||||
budget: &'a mut DecodeBudget,
|
||||
refused: bool,
|
||||
}
|
||||
|
||||
impl<'a> Reads<'a> {
|
||||
fn new(doc: &'a Document, budget: &'a mut DecodeBudget) -> Self {
|
||||
Self { doc, budget, refused: false }
|
||||
}
|
||||
|
||||
/// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given
|
||||
/// by reference too, through a view of the stream that copies no more of its dictionary than
|
||||
/// its filters and parameters; `None` when it can't be, noting when the allowance is why.
|
||||
///
|
||||
/// Each of its filter stages is held to `max`, as a decode without an allowance holds it, and
|
||||
/// the stages together to `max` for each of them ([`DecodeBudget::decode_stages_within`]): a
|
||||
/// map of 65,536 CIDs behind ASCIIHex and Flate, its Flate data smaller than the map, makes
|
||||
/// all of `MAX_CID_TO_GID` in its last stage, with the hex stage's output on top. So a stream
|
||||
/// reads here when it reads without an allowance and what is left pays for it. A stream with a
|
||||
/// stage past `max` is unread. When what is left is more than what the stages before that one
|
||||
/// made and `max`, it is charged those two and isn't refused; otherwise, with more left than
|
||||
/// the stages' total (behind one filter, `max`), it is charged that total and isn't refused;
|
||||
/// with no more left than that total, it spends all of it and counts as refused.
|
||||
fn decode(&mut self, stream: &Stream, max: usize) -> Option<Vec<u8>> {
|
||||
match self.budget.decode_stages_within(self.doc, stream, max) {
|
||||
Ok(data) => Some(data),
|
||||
Err(_) => {
|
||||
self.refused |= self.budget.remaining() == 0;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes.
|
||||
const MAX_TYPE3_GLYPHS: usize = 256;
|
||||
/// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped.
|
||||
@@ -372,8 +448,8 @@ const MAX_TYPE3_EXTENT: f64 = 1e6;
|
||||
/// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences`
|
||||
/// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a
|
||||
/// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`]
|
||||
/// descriptions within [`MAX_TYPE3_FONT_BYTES`].
|
||||
fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> {
|
||||
/// descriptions within [`MAX_TYPE3_FONT_BYTES`], each charged to the reading's allowance too.
|
||||
fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option<([f64; 4], bool)> {
|
||||
let procs = dict(doc, font.get(b"CharProcs"))?;
|
||||
let mut names: Vec<&[u8]> = Vec::new();
|
||||
// Whether every glyph that can be drawn is read; if not, the union may be short of them.
|
||||
@@ -401,12 +477,30 @@ fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> {
|
||||
} else {
|
||||
names.iter().filter_map(|n| procs.get(n)).collect()
|
||||
};
|
||||
let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0);
|
||||
// What is left of the font's own total: the descriptions decode against the reading's
|
||||
// allowance, which charges them, and each within what is left of this.
|
||||
let mut left = MAX_TYPE3_FONT_BYTES;
|
||||
let mut union: Option<[f64; 4]> = None;
|
||||
for proc in chosen {
|
||||
let Object::Stream(s) = &*doc.resolve(proc) else { continue };
|
||||
let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else {
|
||||
if budget.remaining() == 0 {
|
||||
// Once the reading's allowance is spent the rest go unread, before their parameters are
|
||||
// resolved.
|
||||
if reads.budget.refuses(s) {
|
||||
reads.refused = true;
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let before = reads.budget.remaining();
|
||||
let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data);
|
||||
left = left.saturating_sub(before.saturating_sub(reads.budget.remaining()));
|
||||
let Ok(data) = read else {
|
||||
if reads.budget.remaining() == 0 {
|
||||
// The rest go unread too, for want of the reading's allowance.
|
||||
reads.refused = true;
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
if left == 0 {
|
||||
// The rest go unread too.
|
||||
complete = false;
|
||||
break;
|
||||
@@ -785,11 +879,25 @@ impl Metrics {
|
||||
code_len: 1,
|
||||
explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(),
|
||||
outlined_cids: Vec::new(),
|
||||
past_allowance: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The metrics of `font`. Each stream they come from decodes within its own limit (16 MiB for
|
||||
/// a CMap or a program, 4 MiB for a Type 3 font's glyph descriptions together), and again on
|
||||
/// every call: a caller that reads many fonts, or one font many times, shares an allowance
|
||||
/// through [`Metrics::from_dict_within`].
|
||||
pub fn from_dict(doc: &Document, font: &Dict) -> Self {
|
||||
let mut m = Self::read_metrics(doc, font);
|
||||
// No total, so nothing is refused: each stream stays within its own limit, as it always has.
|
||||
Self::from_dict_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0))
|
||||
}
|
||||
|
||||
/// [`Metrics::from_dict`], decoding against `budget`, which the caller shares across the fonts
|
||||
/// it reads (a page's, say): each stream pays for what it decodes, usable or not. A stream the
|
||||
/// allowance can't pay for is left unread, and [`Metrics::past_allowance`] says so.
|
||||
pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self {
|
||||
let mut reads = Reads::new(doc, budget);
|
||||
let mut m = Self::read_metrics(doc, font, &mut reads);
|
||||
let base = font
|
||||
.name(b"BaseFont")
|
||||
.map(|name| String::from_utf8_lossy(name).into_owned())
|
||||
@@ -806,17 +914,18 @@ impl Metrics {
|
||||
m.italic |= italic;
|
||||
m.base_font = base;
|
||||
if m.composite && m.subset {
|
||||
m.outlined_cids = cids_with_outlines(doc, font);
|
||||
m.outlined_cids = cids_with_outlines(doc, font, &mut reads);
|
||||
}
|
||||
m.code_len = match &m.codes {
|
||||
Codes::One => 1,
|
||||
Codes::Two => 2,
|
||||
Codes::Ranges(r) => r.last().map_or(2, |x| x.0),
|
||||
};
|
||||
m.unicode = unicode_map(doc, font, m.composite);
|
||||
m.unicode = unicode_map(doc, font, m.composite, &mut reads);
|
||||
if m.std14.is_some() {
|
||||
embedded_widths(doc, font, &mut m);
|
||||
embedded_widths(doc, font, &mut m, &mut reads);
|
||||
}
|
||||
m.past_allowance = reads.refused;
|
||||
let mut reverse = HashMap::new();
|
||||
for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) {
|
||||
if let Some(ch) = only_char(text)
|
||||
@@ -837,7 +946,7 @@ impl Metrics {
|
||||
m
|
||||
}
|
||||
|
||||
fn read_metrics(doc: &Document, font: &Dict) -> Self {
|
||||
fn read_metrics(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Self {
|
||||
let mut m = Metrics::fallback();
|
||||
m.std14 = None;
|
||||
let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec();
|
||||
@@ -850,7 +959,7 @@ impl Metrics {
|
||||
m.codes = Codes::Two;
|
||||
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
|
||||
&& let Object::Stream(s) = &*e
|
||||
&& let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES)
|
||||
&& let Some(data) = reads.decode(s, MAX_CMAP_BYTES)
|
||||
{
|
||||
let (spaces, cids) = parse_cmap(&data);
|
||||
if !spaces.is_empty() {
|
||||
@@ -915,7 +1024,7 @@ impl Metrics {
|
||||
// A union cut short by a limit may miss glyphs that are drawn, so a nonzero
|
||||
// `/FontBBox` comes first then; all zero, the partial union is still better
|
||||
// than no box. A box with no height in text space gives way to the next.
|
||||
let glyphs = type3_glyph_box(doc, font);
|
||||
let glyphs = type3_glyph_box(doc, font, reads);
|
||||
// All zero: no assumption about glyph sizes (Table 110).
|
||||
let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0));
|
||||
let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0));
|
||||
@@ -1088,8 +1197,14 @@ impl Metrics {
|
||||
}
|
||||
|
||||
/// The bytes that show `text` in this font, or `None` if some character has no code or
|
||||
/// no glyph in it (the caller then substitutes another font).
|
||||
/// no glyph in it, or the metrics were read past their allowance (the caller then substitutes
|
||||
/// another font). Empty text needs no verified glyph and always encodes.
|
||||
pub fn encode(&self, text: &str) -> Option<Vec<u8>> {
|
||||
// A refused read cannot establish the mappings or glyphs needed to write new text.
|
||||
// Empty text needs neither and remains usable for erasing existing text.
|
||||
if self.past_allowance && !text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The lowest code showing each wanted character. Explicit entries and the encoding are
|
||||
// checked one by one; each range is asked for the wanted characters it can show.
|
||||
let wanted: HashSet<char> = text.chars().collect();
|
||||
@@ -1432,7 +1547,7 @@ pub fn glyph_unicode(name: &str) -> Option<char> {
|
||||
/// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads
|
||||
/// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and
|
||||
/// extraction agree: `€` for code 0x80, which Standard leaves without a character.
|
||||
fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
fn symbolic_glyph_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> {
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag};
|
||||
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() };
|
||||
@@ -1443,7 +1558,7 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { return Vec::new() };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() };
|
||||
let Ok(cmap) = face.cmap() else { return Vec::new() };
|
||||
// A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft
|
||||
@@ -1534,7 +1649,7 @@ fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str],
|
||||
|
||||
/// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name
|
||||
/// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding.
|
||||
fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
|
||||
fn unicode_map(doc: &Document, font: &Dict, composite: bool, reads: &mut Reads<'_>) -> Unicode {
|
||||
let mut out = Unicode::default();
|
||||
if !composite {
|
||||
let enc = font.get(b"Encoding").map(|e| doc.resolve(e));
|
||||
@@ -1579,8 +1694,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
|
||||
// A font whose `post` table names its glyphs reads by those names; one that names none
|
||||
// and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback.
|
||||
if base_name.is_none() && !symbolic_builtin {
|
||||
out.encoding.extend(symbolic_glyph_texts(doc, font));
|
||||
out.encoding.extend(symbolic_mac_roman_texts(doc, font));
|
||||
out.encoding.extend(symbolic_glyph_texts(doc, font, reads));
|
||||
out.encoding.extend(symbolic_mac_roman_texts(doc, font, reads));
|
||||
}
|
||||
if let Some(Object::Dict(d)) = enc.as_deref()
|
||||
&& let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned())
|
||||
@@ -1606,10 +1721,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
|
||||
}
|
||||
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
|
||||
Some(Object::Stream(s)) => {
|
||||
// With `/DecodeParms` given by reference, as every stream of the font is read here,
|
||||
// and as the redaction proof reads the map: the two must get the same text, or a copy
|
||||
// redaction rightly left counts as a survivor.
|
||||
if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) {
|
||||
// Reads resolves indirect parameters too, matching the redaction proof's text.
|
||||
if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) {
|
||||
parse_to_unicode(&data, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -1657,7 +1770,7 @@ fn identity_to_unicode() -> Vec<u8> {
|
||||
/// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at
|
||||
/// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text
|
||||
/// layer reads the same codes the same way (vendored hayro-interpret patch (101)).
|
||||
fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
fn symbolic_mac_roman_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> {
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag};
|
||||
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() };
|
||||
@@ -1668,7 +1781,7 @@ fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { return Vec::new() };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() };
|
||||
// A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post`
|
||||
// table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it)
|
||||
|
||||
+510
-10
@@ -178,9 +178,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
/// Inter with its cmap replaced by a Mac format-6 one mapping code 65 to Ω's glyph, which egui
|
||||
/// can't use; and that glyph.
|
||||
fn mac_subset() -> (Vec<u8>, skrifa::GlyphId) {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let face = skrifa::FontRef::new(original).unwrap();
|
||||
let expected = face.charmap().map('Ω').unwrap();
|
||||
@@ -194,6 +195,13 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes());
|
||||
subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes());
|
||||
subset.extend_from_slice(&cmap);
|
||||
(subset, expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let (subset, expected) = mac_subset();
|
||||
let face = skrifa::FontRef::new(&subset).unwrap();
|
||||
assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6");
|
||||
assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected));
|
||||
@@ -214,6 +222,331 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none());
|
||||
}
|
||||
|
||||
/// The metrics read that adapts a program's cmap is paid for from the editor's allowance too: a
|
||||
/// step short of the program, its ToUnicode and the program again for its widths and for the
|
||||
/// readings of its codes by glyph name and as Mac OS Roman, the metrics are read past the allowance
|
||||
/// and no copy is adapted.
|
||||
#[test]
|
||||
fn adapting_a_programs_cmap_is_paid_for() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let (subset, expected) = mac_subset();
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let cmap = b"1 beginbfchar <41> <03A9> endbfchar";
|
||||
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap)));
|
||||
let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]);
|
||||
let cost = 4 * subset.len() + cmap.len();
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap();
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(cost - 1, 0)).is_none());
|
||||
// A composite font whose codes aren't Identity-H can't be adapted, so its metrics aren't
|
||||
// read: the program is all it pays for.
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let mut composite = source.clone();
|
||||
composite.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
assert!(embedded_sfnt_within(&doc, &composite, &mut budget).is_none());
|
||||
assert_eq!(budget.remaining(), cost - subset.len());
|
||||
}
|
||||
|
||||
/// A Type 0 subset font whose `/Encoding`, program, `/CIDToGIDMap` and ToUnicode are streams, and
|
||||
/// what each decodes to.
|
||||
fn subset_type0(doc: &mut Document, to_unicode: &[u8]) -> (Dict, [usize; 4]) {
|
||||
let encoding = b"1 begincodespacerange <0000> <FFFF> endcodespacerange";
|
||||
let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let map = [0u8, 0, 0, 1, 0, 2];
|
||||
let mut stream = |data: &[u8]| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data))));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), stream(program));
|
||||
let mut descendant = Dict::new();
|
||||
descendant.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
descendant.set(b"CIDToGIDMap".to_vec(), stream(&map));
|
||||
let entries = vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("BaseFont", Object::name("ABCDEF+Inter")),
|
||||
("Encoding", stream(encoding)),
|
||||
("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])),
|
||||
("ToUnicode", stream(to_unicode)),
|
||||
];
|
||||
(font(doc, entries), [encoding.len(), program.len(), map.len(), to_unicode.len()])
|
||||
}
|
||||
|
||||
/// Each stream a font's metrics come from is paid for from the allowance they are read within,
|
||||
/// down to the byte; a stream it can't pay for is left unread, and the metrics say so.
|
||||
#[test]
|
||||
fn a_fonts_streams_are_paid_for_from_its_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let (f, sizes) = subset_type0(&mut doc, b"1 beginbfchar <0001> <0058> endbfchar");
|
||||
let cost: usize = sizes.iter().sum();
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance);
|
||||
assert_eq!((whole.code_len, whole.text_of(1).as_deref()), (2, Some("X")));
|
||||
assert!(!whole.outlined_cids.is_empty(), "the program and its map were read");
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// A byte short, the ToUnicode, read last, is left unread; what came before is kept.
|
||||
let mut budget = DecodeBudget::new(cost - 1, 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
|
||||
assert!(m.past_allowance);
|
||||
assert_eq!((m.code_len, m.text_of(1)), (2, None));
|
||||
assert_eq!(m.outlined_cids, whole.outlined_cids);
|
||||
// With nothing left, none of them is read.
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
|
||||
assert!(m.past_allowance && m.outlined_cids.is_empty());
|
||||
assert_eq!(m.codes, Codes::Two, "the Encoding CMap was left unread");
|
||||
// Only a subset's program says which glyphs it lacks: the whole font pays for its CMaps alone.
|
||||
let mut whole_font = f.clone();
|
||||
whole_font.set(b"BaseFont".to_vec(), Object::name("Inter"));
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let m = Metrics::from_dict_within(&doc, &whole_font, &mut budget);
|
||||
assert!(!m.past_allowance && m.outlined_cids.is_empty());
|
||||
assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]);
|
||||
}
|
||||
|
||||
/// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to
|
||||
/// call the font read past it; a stream with data is left unread, and is.
|
||||
#[test]
|
||||
fn an_empty_stream_reads_with_nothing_left() {
|
||||
let past = |data: &[u8]| {
|
||||
let mut doc = Document::new_empty();
|
||||
let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]);
|
||||
Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance
|
||||
};
|
||||
assert!(!past(b""));
|
||||
assert!(past(b"1 beginbfchar <41> <0058> endbfchar"));
|
||||
}
|
||||
|
||||
/// A stream behind one filter past its own limit, with more left than its limit, is left unread as
|
||||
/// it always was, paying for the limit, and is no reason to call the font read past the allowance.
|
||||
#[test]
|
||||
fn a_cmap_past_its_own_limit_is_not_past_the_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec();
|
||||
cmap.resize(MAX_CMAP_BYTES + 1, b' ');
|
||||
let (f, sizes) = subset_type0(&mut doc, &cmap);
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
|
||||
assert!(!m.past_allowance);
|
||||
assert_eq!(m.text_of(1), None);
|
||||
assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::<usize>() + MAX_CMAP_BYTES);
|
||||
}
|
||||
|
||||
/// A `/CIDToGIDMap` as long as a map can be, which compresses well, reads the same behind no
|
||||
/// filter, Flate, or ASCIIHex and Flate: each stage may make as much as the map's limit, so the hex
|
||||
/// stage's output on top of it doesn't leave it unread. A map past the limit is unread behind each.
|
||||
#[test]
|
||||
fn a_full_cid_map_reads_behind_one_or_two_filters() {
|
||||
let read = |entries: usize, filters: usize| {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, _) = subset_type0(&mut doc, b"");
|
||||
let map: Vec<u8> = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect();
|
||||
let flate = Stream::flate(Dict::new(), &map);
|
||||
let stream = match filters {
|
||||
0 => Stream::from_raw(Dict::new(), map),
|
||||
1 => flate,
|
||||
_ => {
|
||||
let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect();
|
||||
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.into_bytes())
|
||||
}
|
||||
};
|
||||
let map = Object::Ref(doc.add(Object::Stream(stream)));
|
||||
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
Metrics::from_dict(&doc, &f).outlined_cids
|
||||
};
|
||||
let full = read(1 << 16, 1);
|
||||
assert!(!full.is_empty());
|
||||
assert_eq!(read(1 << 16, 0), full);
|
||||
assert_eq!(read(1 << 16, 2), full);
|
||||
for filters in 0..=2 {
|
||||
assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters");
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor's program read holds every filter stage to the program's limit: a program behind
|
||||
/// ASCIIHex and Flate whose Flate stage would make 24 MiB stops at 16 MiB, charged that and what
|
||||
/// the hex stage made, where the stages' total alone would let it make all 24 MiB. A program
|
||||
/// within the limit behind the same two filters reads (see also
|
||||
/// `an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit`).
|
||||
#[test]
|
||||
fn an_editors_program_read_holds_each_stage_to_its_limit() {
|
||||
let mut doc = Document::new_empty();
|
||||
let flate = Stream::flate(Dict::new(), &vec![b' '; 24 << 20]);
|
||||
let hex: Vec<u8> = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
let program = doc.add(Object::Stream(Stream::from_raw(dict, hex)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none());
|
||||
assert_eq!(usize::MAX / 2 - budget.remaining(), flate.raw.len() + MAX_SFNT);
|
||||
}
|
||||
|
||||
/// Each stage of a stream a font's metrics are read from is held to the stream's limit, as a
|
||||
/// decode without an allowance holds it: a `/CIDToGIDMap` behind Flate and ASCIIHex whose Flate
|
||||
/// stage makes hex text past the map's limit is unread, as `Stream::decoded_within` leaves it,
|
||||
/// though the two stages together make less than twice the limit; with its hex text within the
|
||||
/// limit it reads.
|
||||
#[test]
|
||||
fn each_stage_of_a_metrics_stream_is_held_to_its_limit() {
|
||||
let read = |cids: usize| {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, _) = subset_type0(&mut doc, b"");
|
||||
let map: Vec<u8> = (0..cids).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect();
|
||||
let hex: Vec<u8> = map.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
|
||||
let stream = Stream::from_raw(dict, Stream::flate(Dict::new(), &hex).raw.to_vec());
|
||||
let unread = stream.decoded_within(MAX_CID_TO_GID).is_err();
|
||||
let map = Object::Ref(doc.add(Object::Stream(stream)));
|
||||
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
(Metrics::from_dict(&doc, &f).outlined_cids.is_empty(), unread)
|
||||
};
|
||||
// 0.6 of the limit: its hex text, 1.2 of it, is past the limit; the stages make 1.8 of it.
|
||||
assert_eq!(read(MAX_CID_TO_GID * 6 / 20), (true, true));
|
||||
assert_eq!(read(MAX_CID_TO_GID / 4), (false, false));
|
||||
}
|
||||
|
||||
/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program
|
||||
/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though
|
||||
/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a
|
||||
/// byte past it is unread behind either.
|
||||
#[test]
|
||||
fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() {
|
||||
let program = |len: usize, noise: usize| {
|
||||
let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec();
|
||||
// Bytes Flate can't shrink, then zeros.
|
||||
let mut x = 1u32;
|
||||
program.extend((0..noise).map(|_| {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
x.to_be_bytes()[0]
|
||||
}));
|
||||
program.resize(len, 0);
|
||||
program
|
||||
};
|
||||
let read = |program: &[u8], hex: bool| {
|
||||
let mut doc = Document::new_empty();
|
||||
let flate = Stream::flate(Dict::new(), program);
|
||||
let stream = if hex {
|
||||
let digits = b"0123456789ABCDEF";
|
||||
let mut data: Vec<u8> = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect();
|
||||
data.push(b'>');
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
Stream::from_raw(dict, data)
|
||||
} else {
|
||||
flate
|
||||
};
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream))));
|
||||
let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]);
|
||||
embedded_sfnt(&doc, &simple)
|
||||
};
|
||||
let near = program(12 << 20, 8 << 20);
|
||||
let full = program(MAX_SFNT, 0);
|
||||
for hex in [false, true] {
|
||||
assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}");
|
||||
assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}");
|
||||
assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the
|
||||
/// difference from the row above), so read without the predictor it is garbled; with its
|
||||
/// `/DecodeParms` given by reference or direct.
|
||||
fn predicted(doc: &mut Document, data: &[u8], columns: usize, by_reference: bool) -> Object {
|
||||
let mut above = vec![0u8; columns];
|
||||
let mut rows = Vec::new();
|
||||
for row in data.chunks(columns) {
|
||||
rows.push(2);
|
||||
rows.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a)));
|
||||
above[..row.len()].copy_from_slice(row);
|
||||
}
|
||||
let mut s = Stream::flate(Dict::new(), &rows);
|
||||
let parms: Dict =
|
||||
[(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(i64::try_from(columns).unwrap()))].into_iter().collect();
|
||||
let parms = if by_reference { Object::Ref(doc.add(Object::Dict(parms))) } else { Object::Dict(parms) };
|
||||
s.dict.set(b"DecodeParms".to_vec(), parms);
|
||||
Object::Ref(doc.add(Object::Stream(s)))
|
||||
}
|
||||
|
||||
/// A stream whose `/DecodeParms` are given by reference reads as it does with them direct: a PNG
|
||||
/// predictor's tag bytes are taken out of the map, the CMap and the editor's program, not left in.
|
||||
#[test]
|
||||
fn parameters_given_by_reference_are_read() {
|
||||
let read = |by_reference: bool| {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, _) = subset_type0(&mut doc, b"");
|
||||
let map: Vec<u8> = (0..1000u16).flat_map(u16::to_be_bytes).collect();
|
||||
let map = predicted(&mut doc, &map, 2, by_reference);
|
||||
let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec();
|
||||
cmap.resize(48, b' ');
|
||||
let unicode = predicted(&mut doc, &cmap, 16, by_reference);
|
||||
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
f.set(b"ToUnicode".to_vec(), unicode);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
(m.outlined_cids.clone(), m.text_of(1).map(Cow::into_owned))
|
||||
};
|
||||
let direct = read(false);
|
||||
assert!(!direct.0.is_empty());
|
||||
assert_eq!(direct.1.as_deref(), Some("X"));
|
||||
assert_eq!(read(true), direct);
|
||||
let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec();
|
||||
program.resize(program.len().next_multiple_of(4), 0);
|
||||
for by_reference in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let file = predicted(&mut doc, &program, 4, by_reference);
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), file);
|
||||
let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]);
|
||||
assert_eq!(embedded_sfnt(&doc, &simple).as_deref(), Some(program.as_slice()), "by reference: {by_reference}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for
|
||||
/// as many readings as it holds, and the rest are left unread, where each reading used to decode
|
||||
/// it afresh.
|
||||
#[test]
|
||||
fn fonts_naming_one_cmap_share_one_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut cmap = b"1 beginbfchar <41> <0058> endbfchar".to_vec();
|
||||
cmap.resize(1_000_000, b' ');
|
||||
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), &cmap)));
|
||||
let fonts: Vec<Dict> = (0..100)
|
||||
.map(|_| {
|
||||
font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("ToUnicode", Object::Ref(unicode))])
|
||||
})
|
||||
.collect();
|
||||
let mut budget = DecodeBudget::new(64 << 20, 0);
|
||||
let read: Vec<Metrics> = fonts.iter().map(|f| Metrics::from_dict_within(&doc, f, &mut budget)).collect();
|
||||
let whole = read.iter().filter(|m| !m.past_allowance).count();
|
||||
assert_eq!(whole, (64 << 20) / cmap.len());
|
||||
assert!(read[..whole].iter().all(|m| m.text_of(0x41).as_deref() == Some("X")));
|
||||
assert!(read[whole..].iter().all(|m| m.text_of(0x41).as_deref() == Some("A")), "the encoding's meaning, unread past the allowance");
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
|
||||
/// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its
|
||||
/// own names (Inter's version 2.0 table), and none past the last glyph.
|
||||
#[test]
|
||||
@@ -388,6 +721,21 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
|
||||
],
|
||||
);
|
||||
assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), "");
|
||||
// The names are read within the allowance the metrics are read within: the program is paid
|
||||
// for by the glyph-name reading, by the Mac OS Roman one (which finds the glyphs named and
|
||||
// reads nothing) and for the widths. A byte short of the first, the names go unread.
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(4));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
|
||||
let mut budget = DecodeBudget::new(3 * subset.len(), 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), symbolic);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
let short = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(subset.len() - 1, 0));
|
||||
assert!(short.past_allowance);
|
||||
assert_ne!(short.decode(&codes), TEXT, "the names unread");
|
||||
}
|
||||
|
||||
/// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase
|
||||
@@ -603,6 +951,24 @@ fn type3_glyph_byte_limits_leave_honest_metrics() {
|
||||
assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone");
|
||||
}
|
||||
|
||||
/// A Type 3 font's glyph descriptions are paid for from the allowance its metrics are read within,
|
||||
/// each what it decodes to: one the allowance can't pay for is left unread, and the metrics say so.
|
||||
#[test]
|
||||
fn type3_glyph_boxes_are_paid_for_from_the_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let procs = ["1 0 0 -20 1 300 d1", "1 0 0 0 1 400 d1"];
|
||||
let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs);
|
||||
let cost: usize = procs.iter().map(|p| p.len()).sum();
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance);
|
||||
assert_eq!((whole.ascent, whole.descent), (400.0, -20.0));
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(cost - 1, 0));
|
||||
assert!(m.past_allowance, "a byte short, the second description is left unread");
|
||||
}
|
||||
|
||||
/// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent`
|
||||
/// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120).
|
||||
#[test]
|
||||
@@ -1142,7 +1508,7 @@ fn malformed_cmaps_never_panic() {
|
||||
let mut enc = Dict::new();
|
||||
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")]));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]);
|
||||
assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B"));
|
||||
assert_eq!(unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1373,6 +1739,29 @@ fn low_codes_resolve_to_the_covering_range() {
|
||||
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_subset_reads_do_not_encode_unverified_glyphs() {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, sizes) = subset_type0(&mut doc, b"");
|
||||
f.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
f.set(b"ToUnicode".to_vec(), Object::name("Identity-H"));
|
||||
if let Some(Object::Array(descendants)) = f.get_mut(b"DescendantFonts")
|
||||
&& let Some(Object::Dict(descendant)) = descendants.first_mut()
|
||||
{
|
||||
descendant.set(b"DW".to_vec(), Object::Int(1000));
|
||||
}
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance && !whole.outlined_cids.is_empty());
|
||||
assert!(whole.encode("\u{ffff}").is_none());
|
||||
// Refuse the program, then the CID map after reading the complete program.
|
||||
for allowance in [0, sizes[1]] {
|
||||
let partial = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(allowance, 0));
|
||||
assert!(partial.past_allowance);
|
||||
assert_eq!(partial.encode(""), Some(Vec::new()));
|
||||
assert!(partial.encode("\u{ffff}").is_none(), "allowance {allowance} must not turn /DW into proof of a glyph");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`cmap_only_sfnt_named`] without a `post` table.
|
||||
fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec<u8> {
|
||||
cmap_only_sfnt_named(mac, windows, false)
|
||||
@@ -1457,7 +1846,7 @@ fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman(
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(flags));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
|
||||
let u = unicode_map(&doc, &f, false);
|
||||
let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0)));
|
||||
codes.map(|c| u.text(c).unwrap_or_default().into_owned())
|
||||
};
|
||||
// `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and
|
||||
@@ -1492,7 +1881,7 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() {
|
||||
let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))];
|
||||
all.extend(entries);
|
||||
let f = font(&mut doc, all);
|
||||
let u = unicode_map(&doc, &f, false);
|
||||
let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0)));
|
||||
[0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned())
|
||||
};
|
||||
assert_eq!(read(vec![]), ["à", "é"]);
|
||||
@@ -1512,8 +1901,40 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() {
|
||||
assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef");
|
||||
}
|
||||
|
||||
/// The program reads that tell a symbolic TrueType font's codes by its glyph names and as Mac OS
|
||||
/// Roman are each paid for from the allowance the metrics are read within: one it can't pay for is
|
||||
/// left unread, and the metrics say so.
|
||||
#[test]
|
||||
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
|
||||
fn the_symbolic_readings_of_a_program_are_paid_for() {
|
||||
let program = cmap_only_sfnt(Some(&[(0x88, 106)]), &[(0xF088, 106)]);
|
||||
let mut doc = Document::new_empty();
|
||||
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &program)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(4));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(stream));
|
||||
// With `/Widths` the program isn't read for its advances: these are its only reads.
|
||||
let entries = vec![
|
||||
("Subtype", Object::name("TrueType")),
|
||||
("FontDescriptor", Object::Dict(descriptor)),
|
||||
("FirstChar", Object::Int(0x88)),
|
||||
("Widths", Object::Array(vec![Object::Int(500)])),
|
||||
];
|
||||
let f = font(&mut doc, entries);
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance);
|
||||
assert_eq!(whole.text_of(0x88).as_deref(), Some("à"));
|
||||
let mut budget = DecodeBudget::new(2 * program.len(), 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(2 * program.len() - 1, 0));
|
||||
assert!(m.past_allowance, "a byte short, the Mac OS Roman reading's read is left unread");
|
||||
assert_eq!(m.text_of(0x88), None);
|
||||
}
|
||||
|
||||
/// A Type 0 font over Inter's program without its `cmap`, Identity-H, whose `/CIDToGIDMap` sends
|
||||
/// CID 17 to Inter's Ω and whose ToUnicode says code 17 is Ω; also the program object, the
|
||||
/// program, Ω's glyph and what the ToUnicode and the map decode to.
|
||||
fn cmapless_composite(doc: &mut Document) -> (Dict, pdfcraft_cos::ObjRef, Vec<u8>, skrifa::GlyphId, [usize; 2]) {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap();
|
||||
@@ -1525,7 +1946,6 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
subset[12 + index * 16..28 + index * 16].copy_from_slice(record);
|
||||
}
|
||||
assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err());
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
@@ -1535,9 +1955,10 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
let mut descendant = Dict::new();
|
||||
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids));
|
||||
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar")));
|
||||
let cmap = b"1 beginbfchar <0011> <03a9> endbfchar";
|
||||
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), cmap)));
|
||||
let source = font(
|
||||
&mut doc,
|
||||
doc,
|
||||
vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("Encoding", Object::name("Identity-H")),
|
||||
@@ -1545,6 +1966,14 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
("ToUnicode", Object::Ref(unicode)),
|
||||
],
|
||||
);
|
||||
(source, program, subset, expected, [cmap.len(), 36])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (source, program, subset, expected, _) = cmapless_composite(&mut doc);
|
||||
let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too");
|
||||
@@ -1552,3 +1981,74 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") };
|
||||
assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact");
|
||||
}
|
||||
|
||||
/// The metrics read that rebuilds a composite subset's cmap is paid for from the editor's
|
||||
/// allowance, as a simple font's is: the program, its ToUnicode, then its `/CIDToGIDMap`. With the
|
||||
/// ToUnicode left unread no copy is adapted, though what is left would pay for the map.
|
||||
#[test]
|
||||
fn adapting_a_composite_subsets_cmap_is_paid_for() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (source, _, subset, expected, [cmap, map]) = cmapless_composite(&mut doc);
|
||||
let cost = subset.len() + cmap + map;
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap();
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
assert!(cmap > map);
|
||||
assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(subset.len() + cmap - 1, 0)).is_none());
|
||||
}
|
||||
|
||||
/// A composite font whose `/Encoding` isn't Identity-H can't have its cmap rebuilt
|
||||
/// (`composite_glyph_maps` reads only Identity-H codes), so the editor reads its program and no
|
||||
/// more: its ToUnicode and `/CIDToGIDMap` are left for what is left of the allowance.
|
||||
#[test]
|
||||
fn a_composite_font_that_cant_be_adapted_spends_only_its_program() {
|
||||
let mut doc = Document::new_empty();
|
||||
let (source, _, subset, _, [cmap, map]) = cmapless_composite(&mut doc);
|
||||
for encoding in ["Identity-V", "UniJIS-UCS2-H"] {
|
||||
let mut f = source.clone();
|
||||
f.set(b"Encoding".to_vec(), Object::name(encoding));
|
||||
let mut budget = DecodeBudget::new(subset.len() + cmap + map, 0);
|
||||
assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none(), "{encoding}");
|
||||
assert_eq!(budget.remaining(), cmap + map, "{encoding}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A composite font's `/Encoding` given through a reference is the name it refers to, as the
|
||||
/// metrics read it: Identity-H by reference is adapted as Identity-H written directly is.
|
||||
#[test]
|
||||
fn a_composite_fonts_encoding_given_by_reference_is_read() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut source, _, _, expected, _) = cmapless_composite(&mut doc);
|
||||
let identity = doc.add(Object::name("Identity-H"));
|
||||
source.set(b"Encoding".to_vec(), Object::Ref(identity));
|
||||
assert_eq!(Metrics::from_dict(&doc, &source).code_len, 2);
|
||||
let editor = embedded_sfnt(&doc, &source).expect("Identity-H by reference is adapted");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
}
|
||||
|
||||
/// The `/CIDToGIDMap` a composite subset's cmap is rebuilt from reads as the font's other streams
|
||||
/// do: a full map, which compresses well, reads behind ASCIIHex and Flate, though the hex stage's
|
||||
/// output comes on top of the map.
|
||||
#[test]
|
||||
fn a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut source, _, _, expected, _) = cmapless_composite(&mut doc);
|
||||
let mut map = vec![0u8; MAX_CID_TO_GID];
|
||||
map[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes());
|
||||
let flate = Stream::flate(Dict::new(), &map);
|
||||
let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
let map = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, hex.into_bytes()))));
|
||||
let Some(Object::Array(mut descendants)) = source.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
source.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
let editor = embedded_sfnt(&doc, &source).expect("the map behind two filters is read");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! does (a mark takes none); correct maps and unrelated fonts are never changed. No font
|
||||
//! artwork is bundled.
|
||||
|
||||
use super::{Dict, Document, Metrics, dict, only_char, unicode_map};
|
||||
use super::{Dict, Document, Metrics, Reads, dict, identity_h, only_char, unicode_map};
|
||||
use pdfcraft_cos::{DecodeBudget, Object};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -19,15 +19,18 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option<Vec<(u16, char)
|
||||
let family = base.rsplit('+').next()?;
|
||||
if !matches!(family, "THSarabunPSK" | "THSarabunPSK-Bold" | "THSarabunPSK-Italic" | "THSarabunPSK-BoldItalic")
|
||||
|| font.name(b"Subtype") != Some(b"Type0")
|
||||
|| font.name(b"Encoding") != Some(b"Identity-H")
|
||||
|| !identity_h(doc, font)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let desc = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
if desc.name(b"CIDToGIDMap") != Some(b"Identity") {
|
||||
// A map given by reference is the name it refers to. A missing one is taken as Identity, the
|
||||
// default of PDF 1.x (ISO 32000-1), as `composite_glyph_maps` and the renderer take it; PDF 2.0
|
||||
// gives it no default and requires it with a `/FontFile2`.
|
||||
if desc.get(b"CIDToGIDMap").is_some_and(|m| doc.resolve(m).as_name() != Some(b"Identity".as_slice())) {
|
||||
return None;
|
||||
}
|
||||
let map = unicode_map(doc, font, true);
|
||||
let map = unicode_map(doc, font, true, &mut Reads::new(doc, &mut DecodeBudget::new(usize::MAX, 0)));
|
||||
// Five separated consonants and three contextual marks must agree, together with the
|
||||
// corrupt destinations and corrupt range. This is deliberately stricter than font-name
|
||||
// detection: a renamed/subsetted font with different glyph order must not be guessed.
|
||||
@@ -170,7 +173,7 @@ fn embedded_truetype(doc: &Document, descendant: &Dict) -> Option<Vec<u8>> {
|
||||
let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?;
|
||||
let program = doc.resolve(descriptor.get(b"FontFile2")?);
|
||||
let Object::Stream(program) = program.as_ref() else { return None };
|
||||
DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok()
|
||||
DecodeBudget::new(super::MAX_SFNT, 0).decode_with_damage(doc, program, super::MAX_SFNT).ok().map(|(data, _)| data)
|
||||
}
|
||||
|
||||
/// Source glyph positions and the original PDF character codes that draw them. Empty codes
|
||||
@@ -190,14 +193,15 @@ pub(super) fn composite_glyph_maps(
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Option<(BTreeMap<u32, u32>, BTreeMap<u32, u16>)> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
if font.name(b"Encoding") != Some(b"Identity-H") {
|
||||
if !identity_h(doc, font) {
|
||||
return None;
|
||||
}
|
||||
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() {
|
||||
None => None,
|
||||
Some(Object::Name(n)) if n == b"Identity" => None,
|
||||
Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?),
|
||||
// Read as the font's other streams are, a map behind several filters too.
|
||||
Some(Object::Stream(s)) => Some(Reads::new(doc, budget).decode(s, super::MAX_CID_TO_GID)?),
|
||||
_ => return None,
|
||||
};
|
||||
let face = skrifa::FontRef::new(bytes).ok()?;
|
||||
@@ -294,13 +298,17 @@ impl LegacyThaiFont {
|
||||
/// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper
|
||||
/// assumes can't be trusted there, so it is shaped as an ordinary source font.
|
||||
///
|
||||
/// Its streams are decoded against `budget`, which the caller shares between every font it
|
||||
/// reads: what they keep is the programs decoded and the editor cmaps added to them.
|
||||
/// Its streams, its metrics' too, are decoded against `budget`, which the caller shares
|
||||
/// between every font it reads: what they keep is the programs decoded and the editor cmaps
|
||||
/// added to them. Metrics read past it adapt nothing: the font is refused.
|
||||
pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result<Option<Self>, String> {
|
||||
let m = Metrics::from_dict(doc, font);
|
||||
let m = Metrics::from_dict_within(doc, font, budget);
|
||||
if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") {
|
||||
return Ok(None);
|
||||
}
|
||||
if m.past_allowance {
|
||||
return Err("the source Thai font's metrics are past the edit's allowance".into());
|
||||
}
|
||||
if !space.is_finite() || space < 0.0 {
|
||||
return Err("the source space width is invalid".into());
|
||||
}
|
||||
@@ -528,6 +536,46 @@ mod tests {
|
||||
/// An intact legacy map: nominal Thai and two tone alternates.
|
||||
const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
|
||||
/// A THSarabunPSK subset whose own cmap is Latin-only has its Thai mappings rebuilt from its
|
||||
/// `/CIDToGIDMap`. With that map left unread for want of the editor's allowance, the editor
|
||||
/// lists no copy, rather than the program without its Thai mappings.
|
||||
#[test]
|
||||
fn a_map_left_unread_for_the_allowance_lists_no_copy() {
|
||||
let mut doc = Document::new_empty();
|
||||
let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program))));
|
||||
let map: Vec<u8> = (0u16..512).flat_map(u16::to_be_bytes).collect();
|
||||
let cid_to_gid = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &map))));
|
||||
let font = thai_font_over(&mut doc, file, cid_to_gid, INTACT);
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut budget).is_some());
|
||||
let cost = usize::MAX / 2 - budget.remaining();
|
||||
// The map the cmap is rebuilt from is read last: a byte short of the cost, it is refused.
|
||||
let mut short = DecodeBudget::new(cost - 1, 0);
|
||||
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut short).is_none(), "the program is listed without its Thai mappings");
|
||||
assert_eq!(short.refused(), 1);
|
||||
}
|
||||
|
||||
/// `LegacyThaiFont::from_pdf` reads its metrics within the edit's allowance too: the subset's
|
||||
/// program, for its outlined glyphs, and its ToUnicode are paid for on top of what the
|
||||
/// editor's read of it pays. Metrics read past the allowance refuse the font before its
|
||||
/// program is read.
|
||||
#[test]
|
||||
fn a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let program = thai_ordered_program();
|
||||
let font = thai_font(&mut doc, &program, INTACT);
|
||||
let mut editor = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut editor).is_some());
|
||||
let editor_cost = usize::MAX / 2 - editor.remaining();
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut budget).unwrap();
|
||||
assert_eq!(usize::MAX / 2 - budget.remaining(), editor_cost + program.len() + INTACT.len());
|
||||
let mut short = DecodeBudget::new(program.len() + INTACT.len() - 1, 0);
|
||||
let error = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut short).err().unwrap_or_default();
|
||||
assert!(error.contains("past the edit's allowance"), "{error}");
|
||||
}
|
||||
|
||||
/// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai
|
||||
/// codes, and the oversized range first.
|
||||
const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> <ffff> endcodespacerange \
|
||||
@@ -547,6 +595,32 @@ mod tests {
|
||||
let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect();
|
||||
assert_eq!(recovered[&262], 'ั');
|
||||
assert_eq!(recovered[&297], '๘');
|
||||
// `/Encoding` given through a reference is the name it refers to.
|
||||
let mut indirect = font.clone();
|
||||
indirect.set(b"Encoding".to_vec(), Object::Ref(doc.add(Object::name("Identity-H"))));
|
||||
assert_eq!(recovered_thai_map(&doc, &indirect), recovered_thai_map(&doc, &font));
|
||||
// `/CIDToGIDMap` given through a reference is the name it refers to, and a missing one is
|
||||
// taken as Identity, the default of PDF 1.x (ISO 32000-1), as the renderer takes it.
|
||||
let with_map = |map: Option<Object>| {
|
||||
let mut f = font.clone();
|
||||
let Some(Object::Array(desc)) = f.get(b"DescendantFonts").cloned() else { panic!("fixture") };
|
||||
let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") };
|
||||
match map {
|
||||
Some(map) => desc.set(b"CIDToGIDMap".to_vec(), map),
|
||||
None => {
|
||||
desc.remove(b"CIDToGIDMap");
|
||||
}
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)]));
|
||||
f
|
||||
};
|
||||
let by_reference = with_map(Some(Object::Ref(doc.add(Object::name("Identity")))));
|
||||
assert_eq!(recovered_thai_map(&doc, &by_reference), recovered_thai_map(&doc, &font), "a map by reference");
|
||||
assert_eq!(recovered_thai_map(&doc, &with_map(None)), recovered_thai_map(&doc, &font), "a missing map");
|
||||
// Any other map is refused: one that moves glyphs, and another name.
|
||||
let moved = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[0, 1, 0, 0]))));
|
||||
assert_eq!(recovered_thai_map(&doc, &with_map(Some(moved))), None, "a map that moves glyphs");
|
||||
assert_eq!(recovered_thai_map(&doc, &with_map(Some(Object::name("Foo")))), None, "another name");
|
||||
// 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that
|
||||
// put Mai Han-akat one glyph earlier passes every anchor and only this check sees it.
|
||||
let mut shifted = thai_ordered_program();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Memory regression for the font reads charged to an allowance: a stream whose `/DecodeParms`
|
||||
//! is given by reference is read through a view that shares its data and copies no more of its
|
||||
//! dictionary than its filters and parameters, not through a copy of the whole stream. A test
|
||||
//! binary of its own, so that the one test is all the process runs while its peak is measured.
|
||||
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_fonts::pdf::{Metrics, embedded_sfnt_within};
|
||||
|
||||
/// How much the process's peak resident memory grows, in KiB, while `f` runs. `None` where the
|
||||
/// peak can't be reset (`/proc/self/clear_refs` is Linux's).
|
||||
fn peak_growth<T>(f: impl FnOnce() -> T) -> Option<(T, u64)> {
|
||||
fn peak_kib() -> Option<u64> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
std::fs::write("/proc/self/clear_refs", "5").ok()?;
|
||||
let before = peak_kib()?;
|
||||
let out = f();
|
||||
Some((out, peak_kib()?.saturating_sub(before)))
|
||||
}
|
||||
|
||||
/// A stream of `data`, its `/DecodeParms` the reference `parms`, its dictionary holding `junk`.
|
||||
fn stream(doc: &mut Document, data: &[u8], parms: ObjRef, junk: &Object) -> Object {
|
||||
let mut s = Stream::flate(Dict::new(), data);
|
||||
s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
|
||||
s.dict.set(b"Junk".to_vec(), junk.clone());
|
||||
Object::Ref(doc.add(Object::Stream(s)))
|
||||
}
|
||||
|
||||
/// A font whose program and ToUnicode each hold an unrelated array of a million numbers in their
|
||||
/// dictionaries, with `/DecodeParms` given by reference, read eight times within an allowance by
|
||||
/// the editor's program read and by the metrics read: neither copies an array. A read through a
|
||||
/// copy of the stream would add about as much as one array holds.
|
||||
#[test]
|
||||
fn reading_a_fonts_streams_copies_no_dictionary() {
|
||||
let junk = Object::Array(vec![Object::Int(0); 1 << 20]);
|
||||
let array_kib = (std::mem::size_of::<Object>() << 20) as u64 >> 10;
|
||||
let mut doc = Document::new_empty();
|
||||
let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(1))])));
|
||||
let program = stream(&mut doc, include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), parms, &junk);
|
||||
let unicode = stream(&mut doc, b"1 beginbfchar <41> <0058> endbfchar", parms, &junk);
|
||||
drop(junk);
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), program);
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("TrueType"));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
font.set(b"ToUnicode".to_vec(), unicode);
|
||||
let Some(((), growth)) = peak_growth(|| {
|
||||
for _ in 0..8 {
|
||||
let mut budget = DecodeBudget::new(64 << 20, 0);
|
||||
assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_some());
|
||||
let m = Metrics::from_dict_within(&doc, &font, &mut budget);
|
||||
assert_eq!(m.text_of(0x41).as_deref(), Some("X"));
|
||||
}
|
||||
}) else {
|
||||
eprintln!("the peak can't be measured here");
|
||||
return;
|
||||
};
|
||||
assert!(growth < array_kib / 2, "the peak grew {growth} KiB, an array is {array_kib} KiB");
|
||||
}
|
||||
@@ -98,6 +98,12 @@ pub enum Calculate {
|
||||
Simple { op: CalcOp, fields: Vec<String> },
|
||||
/// Simplified field notation, e.g. `Price * Quantity`.
|
||||
Notation(String),
|
||||
/// A custom calculation script, written as given (Field Properties ▸ Calculate ▸ Custom
|
||||
/// calculation script). Reading a field never gives this: a script that isn't one of the
|
||||
/// forms above stays in [`Actions::unsupported`] and [`Scripts::calculate`], where the
|
||||
/// JavaScript engine runs it. Authoring writes it back byte for byte; a blank one writes no
|
||||
/// script.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// A field's recognised scripts.
|
||||
|
||||
@@ -445,6 +445,7 @@ impl Calculate {
|
||||
match self {
|
||||
Calculate::None => Vec::new(),
|
||||
Calculate::Simple { fields, .. } => unique(fields.iter().cloned()),
|
||||
Calculate::Custom(js) => calculation_reads(js),
|
||||
Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token {
|
||||
Notation::Name(name) => Some(name),
|
||||
_ => None,
|
||||
@@ -620,7 +621,8 @@ const MAX_EXPANDED_VALUES: usize = 1 << 20;
|
||||
pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> Option<String> {
|
||||
let num = |s: &String| make_number(s).unwrap_or(0.0);
|
||||
match c {
|
||||
Calculate::None => None,
|
||||
// A custom script is the JavaScript engine's to run.
|
||||
Calculate::None | Calculate::Custom(_) => None,
|
||||
// A product of no values is 0, as an empty sum, average, minimum or maximum is, not the
|
||||
// empty product 1: a blank quantity × price row reads 0, and a total over the rows
|
||||
// doesn't count it as 1. (The form's `values` leaves out a field that has no value.)
|
||||
|
||||
@@ -383,9 +383,12 @@ fn notation_text(src: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/AA /C` JavaScript that stores `c`; `None` when it stores none (no calculation, or a
|
||||
/// blank custom script).
|
||||
pub fn calculate_js(c: &Calculate) -> Option<String> {
|
||||
match c {
|
||||
Calculate::None => None,
|
||||
Calculate::Custom(js) => (!js.trim().is_empty()).then(|| js.clone()),
|
||||
Calculate::Simple { op, fields } => Some(format!(
|
||||
"AFSimple_Calculate({}, new Array({}));",
|
||||
js_str(op.code()),
|
||||
|
||||
@@ -487,6 +487,20 @@ fn destructured_for_of_targets_keep_regex_iterables_opaque() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom calculation script (#6630) is written as given, a blank one as no script; it reads the
|
||||
/// fields it names, and running it is the JavaScript engine's, not the AF calculator's.
|
||||
#[test]
|
||||
fn a_custom_calculation_script_is_kept_as_written() {
|
||||
let js = "event.value = this.getField(\"a\").value * getField(\"b\").value; // getField(\"c\")";
|
||||
let c = Calculate::Custom(js.into());
|
||||
assert_eq!(calculate_js(&c).as_deref(), Some(js));
|
||||
for blank in ["", " \r\n\t"] {
|
||||
assert_eq!(calculate_js(&Calculate::Custom(blank.into())), None, "{blank:?}");
|
||||
}
|
||||
assert_eq!(c.reads(), ["a", "b"]);
|
||||
assert_eq!(calculate(&c, &|_| vec!["2".into()]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_calculations_fold_a_repeated_group_without_collecting_it() {
|
||||
let group = |_: &str| vec!["2".to_string(); 3];
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(crate) use tree::ancestor_sets;
|
||||
pub use edit::set_props;
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
|
||||
pub(crate) use look::button_appearance;
|
||||
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon};
|
||||
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon};
|
||||
pub(crate) use tree::{ensure_form, form_fonts_as_objects};
|
||||
|
||||
/// The smallest width and height of a field's widget, in points: a smaller one is refused.
|
||||
|
||||
@@ -94,6 +94,21 @@ fn button_export_options(f: &Field, index: usize, value: &str) -> Result<Vec<Obj
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The `/AA /C` action that stores `c`; `None` when it stores no calculation. A custom script is
|
||||
/// the author's own text, so it is written as a text string, as `set_field_script` writes it, and
|
||||
/// a letter outside ASCII reads the same in every viewer. The AF calls are written as they are.
|
||||
fn calculate_action(c: &crate::af::Calculate) -> Option<Object> {
|
||||
let js = crate::af::calculate_js(c)?;
|
||||
let text = match c {
|
||||
crate::af::Calculate::Custom(_) => PdfString::text(&js),
|
||||
_ => PdfString::literal(js.into_bytes()),
|
||||
};
|
||||
let mut a = Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), text);
|
||||
Some(Object::Dict(a))
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
|
||||
@@ -328,10 +343,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
if let Some(c) = &props.calculate {
|
||||
aa.remove(b"C");
|
||||
if let Some(js) = crate::af::calculate_js(c) {
|
||||
aa.set(b"C".to_vec(), action(&js));
|
||||
let action = calculate_action(c);
|
||||
let calculated = action.is_some();
|
||||
if let Some(action) = action {
|
||||
aa.set(b"C".to_vec(), action);
|
||||
}
|
||||
crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?;
|
||||
crate::calc_order::keep_listed(doc, f.obj, calculated)?;
|
||||
}
|
||||
doc.update_dict(f.obj, |d| {
|
||||
if aa.is_empty() {
|
||||
|
||||
@@ -315,6 +315,25 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
/// `FormError::NoSuchField` when no field is named `name`.
|
||||
pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
redraw_widgets_of(doc, &f, only)
|
||||
}
|
||||
|
||||
/// [`redraw_field`] for each field [`fields`] lists that `keep` accepts. The form's fields are
|
||||
/// read once: drawing a field changes only its own widgets' appearances, so the list stays true
|
||||
/// for the rest. Looking each one up by name instead reads the whole form again for every field,
|
||||
/// which grows with the square of the field count (a laid out XFA form with a thousand rows holds
|
||||
/// thousands of fields). A failure names the field it stopped at.
|
||||
pub fn redraw_fields(doc: &mut Document, mut keep: impl FnMut(&Document, &Field) -> bool) -> Result<(), FormError> {
|
||||
for f in fields(doc) {
|
||||
if keep(doc, &f) {
|
||||
redraw_widgets_of(doc, &f, None).map_err(|e| FormError::Invalid(format!("{}: {e}", f.name)))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`redraw_widgets`] for the field `f`, as `fields(doc)` read it.
|
||||
fn redraw_widgets_of(doc: &mut Document, f: &Field, only: Option<usize>) -> Result<(), FormError> {
|
||||
let mut undrawn = false;
|
||||
for (index, w) in f.widgets.iter().enumerate() {
|
||||
if only.is_some_and(|only| only != index) {
|
||||
@@ -323,7 +342,7 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>
|
||||
let mut missing = None;
|
||||
let ap = match f.kind {
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
|
||||
let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true);
|
||||
let (mut s, m) = appearance::field_appearance_drawn(doc, f, w, &f.value, true);
|
||||
missing = m;
|
||||
// Thai glyphs are streams of their own.
|
||||
pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict);
|
||||
|
||||
@@ -27,7 +27,7 @@ pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
@@ -51,7 +51,7 @@ pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders};
|
||||
#[cfg(test)]
|
||||
use values::has_password_flag;
|
||||
use values::write_value;
|
||||
pub(crate) use values::{ask_viewers_to_redraw, merged_appearance};
|
||||
pub(crate) use values::{ask_viewers_to_redraw, merged_appearance, redraw_calculated_value};
|
||||
pub use values::{
|
||||
calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values,
|
||||
set_value, set_value_with, set_values,
|
||||
|
||||
@@ -38,6 +38,8 @@ impl FieldEvent {
|
||||
pub struct FieldChange {
|
||||
pub name: String,
|
||||
pub value: Option<Vec<String>>,
|
||||
/// This value came from a Calculate script; redraw it through the field's Format script.
|
||||
pub formatted: bool,
|
||||
pub read_only: Option<bool>,
|
||||
pub required: Option<bool>,
|
||||
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
|
||||
@@ -101,7 +103,7 @@ pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut d
|
||||
let Some(js) = script else { continue };
|
||||
let all = fields(doc);
|
||||
let r = scripts.run(event, js, f, &v, &all);
|
||||
apply_changes(doc, &r.changes, &f.name)?;
|
||||
apply_changes(doc, &r.changes, &f.name, scripts)?;
|
||||
if !r.rc {
|
||||
return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name))));
|
||||
}
|
||||
@@ -127,7 +129,7 @@ pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scrip
|
||||
/// Apply script changes to other fields (values written without re-running their scripts;
|
||||
/// read-only, required and visibility as flags). `except` is the event's own field, whose value
|
||||
/// the event sets.
|
||||
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> {
|
||||
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str, scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -149,6 +151,9 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
|
||||
tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
|
||||
tmp.actions = Default::default();
|
||||
crate::write_value(doc, &tmp, &value, &mut NoScripts)?;
|
||||
if c.formatted {
|
||||
crate::redraw_calculated_value(doc, f, v, scripts)?;
|
||||
}
|
||||
}
|
||||
let mut ff = f.flags;
|
||||
for (on, bit) in [(c.read_only, flags::READ_ONLY), (c.required, flags::REQUIRED)] {
|
||||
@@ -187,7 +192,7 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
|
||||
/// Apply what a button or console script changed (values, read-only, required, visibility),
|
||||
/// then recalculate.
|
||||
pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
apply_changes(doc, changes, "")?;
|
||||
apply_changes(doc, changes, "", scripts)?;
|
||||
crate::recalculate_with(doc, scripts)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1726,6 +1726,70 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
|
||||
assert_eq!(field(&fields(&doc), "total").value, ["ran"]);
|
||||
}
|
||||
|
||||
/// The `/JS` string of field `name`'s `/AA /C` action, as stored.
|
||||
fn calculate_action_js(doc: &Document, name: &str) -> Option<PdfString> {
|
||||
let f = field(&fields(doc), name).obj;
|
||||
let aa = doc.get(f).as_dict().and_then(|d| d.get(b"AA").cloned()).map(|a| doc.resolve(&a))?;
|
||||
let c = aa.as_dict().and_then(|aa| aa.get(b"C").cloned()).map(|c| doc.resolve(&c))?;
|
||||
Some(c.as_dict().and_then(|c| c.get(b"JS")).and_then(|s| s.as_string()).expect("/JS is a string").clone())
|
||||
}
|
||||
|
||||
/// Field Properties ▸ Calculate ▸ Custom calculation script (#6630): the script is written as
|
||||
/// given, as a text string, and reads back as the field's custom script, in the calculation
|
||||
/// order; a blank one removes the script and the field's place in the order.
|
||||
#[test]
|
||||
fn a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it() {
|
||||
use crate::af::Calculate;
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("codice")).unwrap();
|
||||
add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("barcode")).unwrap();
|
||||
let js = "// Code 39: tra asterischi, così\r\nevent.value = \"*\" + this.getField(\"codice\").value + \"*\";";
|
||||
let custom = |js: &str| FieldProps { calculate: Some(Calculate::Custom(js.into())), ..FieldProps::default() };
|
||||
set_props(&mut doc, "barcode", &custom(js)).unwrap();
|
||||
for doc in [doc.clone(), reopen(&doc)] {
|
||||
let all = fields(&doc);
|
||||
let actions = &field(&all, "barcode").actions;
|
||||
assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, Some(js)), "read back as a script");
|
||||
assert!(actions.unsupported.contains(&"calculate"));
|
||||
let stored = calculate_action_js(&doc, "barcode").expect("an /AA /C action");
|
||||
assert_eq!(stored.bytes, pdfcraft_cos::PdfString::text(js).bytes, "a text string, so ì isn't UTF-8 bytes another viewer reads as ì's pieces");
|
||||
let order = calculation_order(&doc, &all);
|
||||
assert_eq!(order.fields.iter().map(|f| (f.name.as_str(), f.listed)).collect::<Vec<_>>(), [("barcode", true)]);
|
||||
assert_eq!(order.fields[0].reads, ["codice"]);
|
||||
}
|
||||
for blank in ["", " \n\t"] {
|
||||
let mut doc = doc.clone();
|
||||
set_props(&mut doc, "barcode", &custom(blank)).unwrap();
|
||||
let all = fields(&doc);
|
||||
let actions = &field(&all, "barcode").actions;
|
||||
assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, None), "{blank:?}");
|
||||
assert!(calculate_action_js(&doc, "barcode").is_none(), "{blank:?} writes no /C");
|
||||
assert!(calculation_order(&doc, &all).fields.is_empty(), "{blank:?} leaves the calculation order");
|
||||
}
|
||||
}
|
||||
|
||||
/// A sum and a notation are still written as the AF calls they are, byte for byte, beside the
|
||||
/// custom script that is written as a text string (#6630).
|
||||
#[test]
|
||||
fn a_sum_and_a_notation_are_written_as_their_af_calls() {
|
||||
use crate::af::{CalcOp, Calculate, calculate_js};
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
for (i, name) in ["a", "b", "total"].into_iter().enumerate() {
|
||||
let y = 700.0 - 40.0 * i as f64;
|
||||
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
|
||||
}
|
||||
let sum = Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] };
|
||||
for c in [sum, Calculate::Notation("a * b".into())] {
|
||||
set_props(&mut doc, "total", &FieldProps { calculate: Some(c.clone()), ..FieldProps::default() }).unwrap();
|
||||
let js = calculate_js(&c).unwrap();
|
||||
assert_eq!(calculate_action_js(&doc, "total").map(|s| s.bytes), Some(js.into_bytes()), "{c:?}");
|
||||
let all = fields(&reopen(&doc));
|
||||
assert_eq!((&field(&all, "total").actions.calculate, &field(&all, "total").actions.scripts.calculate), (&c, &None));
|
||||
}
|
||||
}
|
||||
|
||||
/// An unsupported first AF call keeps the entire script and reaches the caller's interpreter.
|
||||
#[test]
|
||||
fn unsupported_first_validation_call_preserves_custom_script_dispatch() {
|
||||
@@ -3551,6 +3615,7 @@ fn recalc_chain_where_script_changes_checkbox() {
|
||||
changes: vec![FieldChange {
|
||||
name: "agree".into(),
|
||||
value: Some(vec!["Yes".into()]),
|
||||
formatted: false,
|
||||
read_only: None,
|
||||
required: None,
|
||||
display: None,
|
||||
@@ -5721,6 +5786,34 @@ fn set_values_reads_the_form_once_not_per_value() {
|
||||
assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
|
||||
}
|
||||
|
||||
/// Redrawing every field of a large form reads the form once, not once per field: a laid out XFA
|
||||
/// form with a thousand rows holds thousands of fields, and reading them all again for each one
|
||||
/// took 15 s a relayout. The fields `keep` turns down keep the appearance they had.
|
||||
#[test]
|
||||
fn redraw_fields_reads_the_form_once_however_many_fields_it_holds() {
|
||||
for n in [250u32, 1000] {
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 5)).collect();
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" ")));
|
||||
objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" ")));
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /V (v{i}) /Rect [50 0 250 10] /P 3 0 R >>"));
|
||||
}
|
||||
let mut doc = document(&objs);
|
||||
let before = form_reads();
|
||||
redraw_fields(&mut doc, |_, f| f.name != "f0").unwrap();
|
||||
assert_eq!(form_reads() - before, 1, "{n} fields");
|
||||
let all = fields(&doc);
|
||||
let has_ap = |name: &str| field(&all, name).widgets.iter().all(|w| doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP")));
|
||||
assert!(!has_ap("f0"), "turned down: left as it was");
|
||||
assert!((1..n).all(|i| has_ap(&format!("f{i}"))), "{n} fields");
|
||||
assert!(ap(&doc, &field(&all, &format!("f{}", n - 1)).widgets[0]).contains(&format!("(v{})", n - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and
|
||||
/// the group's buttons in the calculation order; and a text field `name` with two widgets.
|
||||
fn radio_group_form() -> Document {
|
||||
|
||||
@@ -224,7 +224,7 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
|
||||
// field to the event, so that value is the result unless the script also assigned
|
||||
// `event.value`, even the value it already had.
|
||||
let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default());
|
||||
scripting::apply_changes(doc, &r.changes, &f.name)?;
|
||||
scripting::apply_changes(doc, &r.changes, &f.name, scripts)?;
|
||||
if !r.changes.is_empty() {
|
||||
all = fields(doc);
|
||||
}
|
||||
@@ -473,6 +473,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraw a calculated value without running input validation or calculation again.
|
||||
pub(crate) fn redraw_calculated_value(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
redraw(doc, f, values, scripts)
|
||||
}
|
||||
|
||||
/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of
|
||||
/// its `count` options are among the rows it draws, as in other viewers: a pick below the last
|
||||
/// visible row would otherwise not show (#1524). `None` for any other field.
|
||||
|
||||
@@ -100,6 +100,9 @@ struct Interp<'h> {
|
||||
/// burning a core to the loop limit).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
deadline: Option<std::time::Instant>,
|
||||
/// `deadline` passed and stopped the script: the run reports as abandoned.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
out_of_time: bool,
|
||||
}
|
||||
|
||||
type R<T> = Result<T, String>;
|
||||
@@ -151,6 +154,8 @@ pub(crate) fn run_formcalc_at(
|
||||
exited: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
deadline: deadline.and_then(|d| std::time::Instant::now().checked_add(d)),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
out_of_time: false,
|
||||
};
|
||||
let mut out = XfaOutcome::default();
|
||||
match interp.block(&body) {
|
||||
@@ -165,6 +170,19 @@ pub(crate) fn run_formcalc_at(
|
||||
}
|
||||
Err(e) => out.error = Some(e),
|
||||
}
|
||||
// The caller stops waiting at the same time limit, so which of the two notices first is
|
||||
// down to the scheduler. The outcome must not depend on it: stopped by the clock, the run
|
||||
// reports what an abandoned one does, the error and no changes. Keeping the changes made
|
||||
// it a race: a loop adding rows until the clock stopped it left the form unchanged on one
|
||||
// run and 1000 rows longer on the next (each laid out again, tens of seconds).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (true, Some(d)) = (interp.out_of_time, deadline) {
|
||||
return XfaOutcome {
|
||||
error: Some(format!("the script ran longer than {:.1} s and was abandoned", d.as_secs_f64())),
|
||||
abandoned: true,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
out.notes = host.notes();
|
||||
out.effects = host.effects;
|
||||
out.console = host.console;
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Interp<'_> {
|
||||
&& let Some(d) = self.deadline
|
||||
&& std::time::Instant::now() > d
|
||||
{
|
||||
self.out_of_time = true;
|
||||
return Err(RAN_TOO_LONG.into());
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
+233
-8
@@ -1,10 +1,10 @@
|
||||
use super::document::{field_object, get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form};
|
||||
use super::format::{printd, printf, printx, scand};
|
||||
use super::host::{arg, function, host, s, text, text_line};
|
||||
use super::host::{arg, function, host, s, strings, text, text_line};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request};
|
||||
use super::{
|
||||
Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer,
|
||||
js_string,
|
||||
Arc, Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer,
|
||||
Source, error, js_string,
|
||||
};
|
||||
|
||||
// ── app, util, console ──────────────────────────────────────────────────────────────────────
|
||||
@@ -120,6 +120,36 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// The attached file `exportDataObject` names, and whether to open it: from an object
|
||||
/// (`{cName, nLaunch}`) or from the arguments (`cName, cDIPath, bAllowAuth, nLaunch`). `nLaunch`
|
||||
/// 1 and 2 open the file; 0 (the default) and any other value save it.
|
||||
pub(crate) fn data_object(args: &[JsValue], ctx: &mut Context) -> JsResult<(String, bool)> {
|
||||
let first = arg(args, 0);
|
||||
// Any object of named parameters is read by key, one without `cName` too, so a missing name
|
||||
// is refused rather than read as the object's text.
|
||||
let named = first.as_object().filter(|o| !o.is_array() && !o.is_callable());
|
||||
let name = match &named {
|
||||
Some(o) => text_line(&o.get(js_string!("cName"), ctx)?, ctx)?,
|
||||
None => text_line(&first, ctx)?,
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(error("exportDataObject needs cName, the attached file's name"));
|
||||
}
|
||||
let launch = match &named {
|
||||
Some(o) => o.get(js_string!("nLaunch"), ctx)?,
|
||||
None => arg(args, 3),
|
||||
};
|
||||
let launch = if launch.is_undefined() || launch.is_null() { 0.0 } else { launch.to_number(ctx)? };
|
||||
Ok((name, launch == 1.0 || launch == 2.0))
|
||||
}
|
||||
|
||||
/// `this.exportDataObject(…)`: asks the viewer to open or save an attached file.
|
||||
fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let (name, open) = data_object(args, ctx)?;
|
||||
host(ctx)?.borrow_mut().request(Request::OpenAttachment { name, open });
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print
|
||||
/// and Save As. Other items do nothing.
|
||||
pub(crate) fn menu_item(name: &str) -> Option<Request> {
|
||||
@@ -147,6 +177,55 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// Return a page box in Acrobat's rotated-user-space order: left, top, right, bottom.
|
||||
fn get_page_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let name = arg(args, 0);
|
||||
let name = if name.is_undefined() {
|
||||
"Crop".to_string()
|
||||
} else {
|
||||
name.as_string().ok_or_else(|| error("getPageBox box name must be a string"))?.to_std_string_escaped()
|
||||
};
|
||||
let which = match name.as_str() {
|
||||
"Media" | "MediaBox" => 0,
|
||||
"Crop" | "CropBox" => 1,
|
||||
"Bleed" | "BleedBox" => 2,
|
||||
"Trim" | "TrimBox" => 3,
|
||||
"Art" | "ArtBox" => 4,
|
||||
_ => return Err(error("getPageBox box name must be Media, Crop, Trim, Bleed, or Art")),
|
||||
};
|
||||
let page_value = arg(args, 1);
|
||||
let page = if page_value.is_undefined() {
|
||||
0
|
||||
} else {
|
||||
if !page_value.is_number() {
|
||||
return Err(error("getPageBox page must be a non-negative in-range integer"));
|
||||
}
|
||||
let page = page_value.to_number(ctx)?;
|
||||
if !page.is_finite() || page < 0.0 || page.fract() != 0.0 || page >= usize::MAX as f64 {
|
||||
return Err(error("getPageBox page must be a non-negative in-range integer"));
|
||||
}
|
||||
page as usize
|
||||
};
|
||||
let (rect, rotation, crop) = {
|
||||
let h = host(ctx)?.borrow();
|
||||
let boxes = h.doc.page_boxes.get(page).ok_or_else(|| error("getPageBox page is outside the document"))?;
|
||||
let rotation = h.doc.page_rotations.get(page).copied().unwrap_or(0);
|
||||
(boxes[which], rotation, boxes[1])
|
||||
};
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let [cx0, cy0, cx1, cy1] = crop;
|
||||
let rotated = match rotation {
|
||||
90 => [y0 - cy0, cx1 - x0, y1 - cy0, cx1 - x1],
|
||||
180 => [cx1 - x1, cy1 - y0, cx1 - x0, cy1 - y1],
|
||||
270 => [cy1 - y1, x1 - cx0, cy1 - y0, x0 - cx0],
|
||||
_ => [x0 - cx0, y1 - cy0, x1 - cx0, y0 - cy0],
|
||||
};
|
||||
if !rotated.iter().all(|value| value.is_finite()) {
|
||||
return Err(error("getPageBox returned non-finite coordinates"));
|
||||
}
|
||||
Ok(JsArray::from_iter(rotated.into_iter().map(JsValue::from), ctx).into())
|
||||
}
|
||||
|
||||
/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to
|
||||
/// type one.
|
||||
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
@@ -165,6 +244,149 @@ fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// Recalculate fields in the document's `/CO` order against the writes already made by this
|
||||
/// script. A small depth cap stops calculate scripts from recursing forever.
|
||||
fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
const MAX_CALCULATE_DEPTH: usize = 8;
|
||||
let calculations = {
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
if h.calculate_depth >= MAX_CALCULATE_DEPTH {
|
||||
return Err(error("calculateNow recursion limit exceeded"));
|
||||
}
|
||||
h.calculate_depth += 1;
|
||||
Arc::clone(&h.calculations)
|
||||
};
|
||||
let previous_event = match ctx.global_object().get(js_string!("event"), ctx) {
|
||||
Ok(event) => event,
|
||||
Err(error) => {
|
||||
if let Ok(shared) = host(ctx) {
|
||||
let mut h = shared.borrow_mut();
|
||||
h.calculate_depth = h.calculate_depth.saturating_sub(1);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let result = (|| {
|
||||
for (name, script) in calculations.iter() {
|
||||
{
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
if h.calculate_steps >= super::run::MAX_CALCULATE_STEPS {
|
||||
return Err(error("calculateNow evaluation limit exceeded"));
|
||||
}
|
||||
h.calculate_steps = h.calculate_steps.saturating_add(1);
|
||||
}
|
||||
let Some(field) = host(ctx)?.borrow().fields.iter().find(|f| f.name == *name).cloned() else { continue };
|
||||
if let Some(why) = super::run::refuse(script) {
|
||||
host(ctx)?.borrow_mut().print(format!("calculation script for {name} failed: {why}"));
|
||||
continue;
|
||||
}
|
||||
let event = Event::field("Calculate", name, &field.value.first().cloned().unwrap_or_default());
|
||||
let (object, _) = event_object(ctx, &event)?;
|
||||
ctx.global_object().set(js_string!("event"), object.clone(), true, ctx)?;
|
||||
let (fields_before, changed_before, formatted_before) = {
|
||||
let h = host(ctx)?.borrow();
|
||||
(Arc::clone(&h.fields), h.changed.clone(), h.formatted.clone())
|
||||
};
|
||||
if let Err(cause) = ctx.eval(Source::from_bytes(script)) {
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
h.fields = fields_before;
|
||||
h.changed = changed_before;
|
||||
h.formatted = formatted_before;
|
||||
h.print(format!("calculation script for {name} failed: {cause}"));
|
||||
continue;
|
||||
}
|
||||
let rc = object.get(js_string!("rc"), ctx)?;
|
||||
if !rc.to_boolean() {
|
||||
continue;
|
||||
}
|
||||
let value = object.get(js_string!("value"), ctx)?;
|
||||
let value = text(&value, ctx)?;
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
if let Some((index, _)) = h.fields.iter().enumerate().find(|(_, f)| f.name == *name) {
|
||||
let target = Arc::make_mut(&mut h.fields).get_mut(index).ok_or_else(|| error("the field is no longer available"))?;
|
||||
target.value = vec![value];
|
||||
h.changed.insert(index);
|
||||
h.formatted.insert(index);
|
||||
}
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
})();
|
||||
let restored = ctx.global_object().set(js_string!("event"), previous_event, true, ctx).map(|_| JsValue::undefined());
|
||||
if let Ok(shared) = host(ctx) {
|
||||
let mut h = shared.borrow_mut();
|
||||
h.calculate_depth = h.calculate_depth.saturating_sub(1);
|
||||
}
|
||||
result.and(restored)
|
||||
}
|
||||
|
||||
/// Acrobat's built-in calculation helper, used by the scripts written by its Calculate tab.
|
||||
/// `AFSimple_Calculate(op, names)`, with the semantics of the native Simple calculation
|
||||
/// (`pdfcraft_forms::af::calculate`), so a total read right after `calculateNow()` is the one the
|
||||
/// commit path writes: a name also stands for every field under it (`b` sums `b.c` and `b.d`),
|
||||
/// each field counts its first value, and values are read as `AFMakeNumber` reads typed text
|
||||
/// (currency, `%`, group separators, a comma decimal separator), anything else counting as 0.
|
||||
fn af_simple_calculate(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
const MAX_EXPANDED_VALUES: usize = 1 << 20;
|
||||
let op = text(&arg(args, 0), ctx)?;
|
||||
let names = strings(&arg(args, 1), ctx)?;
|
||||
let values: Vec<f64> = {
|
||||
let shared = host(ctx)?;
|
||||
let h = shared.borrow();
|
||||
let mut values = Vec::new();
|
||||
for name in &names {
|
||||
let prefix = format!("{name}.");
|
||||
for f in h.fields.iter().filter(|f| f.name == *name || f.name.starts_with(&prefix)) {
|
||||
if let Some(v) = f.value.first() {
|
||||
if values.len() >= MAX_EXPANDED_VALUES {
|
||||
return Ok(s("0"));
|
||||
}
|
||||
values.push(make_number(v).unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
};
|
||||
let result = match op.as_str() {
|
||||
"SUM" => values.iter().sum(),
|
||||
// An empty product is 0, as the native calculation has it, not 1.
|
||||
"PRD" if values.is_empty() => 0.0,
|
||||
"PRD" => values.iter().product(),
|
||||
"AVG" if values.is_empty() => 0.0,
|
||||
"AVG" => values.iter().sum::<f64>() / values.len() as f64,
|
||||
"MIN" => values.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
"MAX" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
_ => 0.0,
|
||||
};
|
||||
Ok(s(&if result.is_finite() { js_number(result) } else { "0".into() }))
|
||||
}
|
||||
|
||||
/// `AFMakeNumber`'s reading of typed text, as `pdfcraft_forms::af::make_number` has it: currency
|
||||
/// symbols, spaces, `'` and `%` ignored, `(7)` negative, and a comma the decimal separator when
|
||||
/// it comes last (`1.234,56`) or is not followed by exactly three digits.
|
||||
fn make_number(s: &str) -> Option<f64> {
|
||||
let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let neg = t.starts_with('(') && t.ends_with(')');
|
||||
let t = t.trim_start_matches('(').trim_end_matches(')');
|
||||
let normalized = match (t.rfind(','), t.rfind('.')) {
|
||||
(Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."),
|
||||
(Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."),
|
||||
_ => t.replace(',', ""),
|
||||
};
|
||||
let v: f64 = normalized.parse().ok()?;
|
||||
v.is_finite().then_some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
/// A number as JavaScript prints it (integers without a fraction), as the native calculation
|
||||
/// writes it.
|
||||
fn js_number(v: f64) -> String {
|
||||
if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") }
|
||||
}
|
||||
|
||||
fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
// Timers never fire in the sandbox; scripts get an object they can pass to clear*.
|
||||
Ok(ObjectInitializer::new(ctx).build().into())
|
||||
@@ -199,20 +421,23 @@ fn color_object(ctx: &mut Context) -> JsObject {
|
||||
const XFA_VERSION: f64 = 3.5;
|
||||
|
||||
/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`,
|
||||
/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`,
|
||||
/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and
|
||||
/// `color` objects.
|
||||
/// `print`, `submitForm`, `exportDataObject` and others) and properties (`numFields`, `pageNum`,
|
||||
/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`,
|
||||
/// `display` and `color` objects.
|
||||
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let globals: [(&str, usize, NativeFunction); 9] = [
|
||||
let globals: [(&str, usize, NativeFunction); 12] = [
|
||||
("getField", 1, NativeFunction::from_fn_ptr(get_field)),
|
||||
("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)),
|
||||
("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)),
|
||||
("print", 1, NativeFunction::from_fn_ptr(print)),
|
||||
("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)),
|
||||
("mailDoc", 1, NativeFunction::from_fn_ptr(noop)),
|
||||
("calculateNow", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("calculateNow", 0, NativeFunction::from_fn_ptr(calculate_now)),
|
||||
("AFSimple_Calculate", 2, NativeFunction::from_fn_ptr(af_simple_calculate)),
|
||||
("getPageBox", 2, NativeFunction::from_fn_ptr(get_page_box)),
|
||||
("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("dirty", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)),
|
||||
];
|
||||
for (name, len, f) in globals {
|
||||
ctx.register_global_callable(JsString::from(name), len, f)?;
|
||||
|
||||
@@ -8,10 +8,14 @@ use super::{
|
||||
pub(crate) struct Host {
|
||||
pub(crate) fields: Arc<Vec<FieldState>>,
|
||||
pub(crate) changed: BTreeSet<usize>,
|
||||
pub(crate) formatted: BTreeSet<usize>,
|
||||
pub(crate) alerts: Vec<Message>,
|
||||
pub(crate) console: Vec<String>,
|
||||
pub(crate) requests: Vec<Request>,
|
||||
pub(crate) doc: DocInfo,
|
||||
pub(crate) calculations: Arc<Vec<(String, String)>>,
|
||||
pub(crate) calculate_depth: usize,
|
||||
pub(crate) calculate_steps: usize,
|
||||
/// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`].
|
||||
pub(crate) kept: usize,
|
||||
/// Alerts, console lines and requests the limits left out.
|
||||
@@ -68,6 +72,7 @@ impl Host {
|
||||
let bytes = match &r {
|
||||
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
|
||||
Request::OpenAttachment { name, .. } => name.len(),
|
||||
_ => 0,
|
||||
};
|
||||
let kept = self.room(bytes);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`.
|
||||
//! - the document (`this`, also the global object): `getField`, `numFields`,
|
||||
//! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`,
|
||||
//! `calculateNow`, `print`, `submitForm`, `mailDoc`.
|
||||
//! `calculateNow`, `getPageBox`, `print`, `submitForm`, `mailDoc`, `exportDataObject`.
|
||||
//! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`,
|
||||
//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`,
|
||||
//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`,
|
||||
@@ -56,7 +56,7 @@ pub use model::{
|
||||
use run::SCRIPT_STACK;
|
||||
use run::refuse;
|
||||
pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES};
|
||||
pub use run::{run, run_shared};
|
||||
pub use run::{run, run_shared, run_shared_calculations, run_shared_calculations_shared};
|
||||
|
||||
pub mod formcalc;
|
||||
pub mod xfa;
|
||||
|
||||
@@ -114,6 +114,10 @@ pub struct DocInfo {
|
||||
pub page: usize,
|
||||
/// Document information (`this.info.title` etc.): lower-case key, value.
|
||||
pub info: Vec<(String, String)>,
|
||||
/// Effective page boxes in PDF user space, ordered media, crop, bleed, trim, art.
|
||||
pub page_boxes: Vec<[[f64; 4]; 5]>,
|
||||
/// Clockwise page rotations, normalized to 0, 90, 180 or 270 degrees.
|
||||
pub page_rotations: Vec<i64>,
|
||||
/// Whether the viewer highlights form fields (`app.runtimeHighlight`).
|
||||
pub highlight: bool,
|
||||
/// The document has an XFA form, so scripts see `xfa_installed`.
|
||||
@@ -241,6 +245,12 @@ pub enum Request {
|
||||
Beep,
|
||||
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
|
||||
SaveAs,
|
||||
/// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch`
|
||||
/// 1 or 2) or to save (0).
|
||||
OpenAttachment {
|
||||
name: String,
|
||||
open: bool,
|
||||
},
|
||||
/// `app.runtimeHighlight = on`: highlight the form's fields, or stop.
|
||||
Highlight(bool),
|
||||
/// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing.
|
||||
@@ -287,6 +297,8 @@ pub struct Outcome {
|
||||
pub change: String,
|
||||
/// Fields whose value or properties the script changed, as they are now.
|
||||
pub changed: Vec<FieldState>,
|
||||
/// Fields updated by Calculate scripts; their Format script must redraw the appearance.
|
||||
pub formatted: std::collections::BTreeSet<String>,
|
||||
/// The messages the script showed, in order.
|
||||
pub alerts: Vec<Message>,
|
||||
pub console: Vec<String>,
|
||||
|
||||
+50
-16
@@ -16,6 +16,9 @@ pub(crate) const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20;
|
||||
/// length costs the script nothing but the host one string per item; real lists are tiny.
|
||||
pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20;
|
||||
|
||||
/// Maximum calculated field scripts evaluated by `calculateNow()` during one JavaScript run.
|
||||
pub(crate) const MAX_CALCULATE_STEPS: usize = 4096;
|
||||
|
||||
/// Deepest nesting of `(`, `[` and `{`.
|
||||
const MAX_BRACKET_DEPTH: usize = 64;
|
||||
|
||||
@@ -108,6 +111,33 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do
|
||||
/// context; the first field write copies the snapshot, so neither the caller nor another
|
||||
/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form.
|
||||
pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
run_shared_calculations(script, event, doc, fields, doc_scripts, &Arc::new(Vec::new()), limits)
|
||||
}
|
||||
|
||||
/// Like [`run_shared`], with the document's ordered calculated fields available to
|
||||
/// `calculateNow()` while this context is running.
|
||||
pub fn run_shared_calculations(
|
||||
script: &str,
|
||||
event: &Event,
|
||||
doc: &DocInfo,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
doc_scripts: &[String],
|
||||
calculations: &[(String, String)],
|
||||
limits: Limits,
|
||||
) -> Outcome {
|
||||
run_shared_calculations_shared(script, event, doc, fields, doc_scripts, &Arc::new(calculations.to_vec()), limits)
|
||||
}
|
||||
|
||||
/// Like [`run_shared_calculations`], sharing its ordered calculation scripts with the context.
|
||||
pub fn run_shared_calculations_shared(
|
||||
script: &str,
|
||||
event: &Event,
|
||||
doc: &DocInfo,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
doc_scripts: &[String],
|
||||
calculations: &Arc<Vec<(String, String)>>,
|
||||
limits: Limits,
|
||||
) -> Outcome {
|
||||
let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() };
|
||||
if let Some(why) = refuse(script) {
|
||||
return failed(why);
|
||||
@@ -132,10 +162,11 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
|
||||
// The script's thread writes dates as this one asked (`with_english_printd`).
|
||||
let english = crate::format::printd_english();
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-js".into())
|
||||
.stack_size(SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || crate::format::with_english_printd(english, || run_here(script, event, doc, fields, kept, skipped, limits)));
|
||||
let spawned = std::thread::Builder::new().name("pdfcraft-js".into()).stack_size(SCRIPT_STACK).spawn_scoped(scope, || {
|
||||
crate::format::with_english_printd(english, || {
|
||||
run_here(script, event, doc, fields, ScriptSetup { doc_scripts: kept, skipped, calculations }, limits)
|
||||
})
|
||||
});
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
@@ -143,7 +174,7 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
run_here(script, event, doc, fields, kept, skipped, limits)
|
||||
run_here(script, event, doc, fields, ScriptSetup { doc_scripts: kept, skipped, calculations }, limits)
|
||||
}
|
||||
|
||||
/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator
|
||||
@@ -185,15 +216,13 @@ fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String {
|
||||
|
||||
/// `doc_scripts` are the document scripts to run with their places among all of them;
|
||||
/// `skipped` the ones refused before running, with the reason.
|
||||
fn run_here(
|
||||
script: &str,
|
||||
event: &Event,
|
||||
doc: &DocInfo,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
doc_scripts: &[(usize, &str)],
|
||||
skipped: &[(usize, String)],
|
||||
limits: Limits,
|
||||
) -> Outcome {
|
||||
struct ScriptSetup<'a> {
|
||||
doc_scripts: &'a [(usize, &'a str)],
|
||||
skipped: &'a [(usize, String)],
|
||||
calculations: &'a Arc<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, setup: ScriptSetup<'_>, limits: Limits) -> Outcome {
|
||||
let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
@@ -206,15 +235,19 @@ fn run_here(
|
||||
ctx.insert_data(RefCell::new(Host {
|
||||
fields,
|
||||
changed: BTreeSet::new(),
|
||||
formatted: BTreeSet::new(),
|
||||
alerts: Vec::new(),
|
||||
console: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
doc: doc.clone(),
|
||||
calculations: Arc::clone(setup.calculations),
|
||||
calculate_depth: 0,
|
||||
calculate_steps: 0,
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
}));
|
||||
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
|
||||
let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
|
||||
let mut doc_errors: Vec<(usize, String)> = setup.skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
|
||||
let result = (|| -> JsResult<()> {
|
||||
install(&mut ctx)?;
|
||||
let (ev, value_store) = event_object(&mut ctx, event)?;
|
||||
@@ -222,7 +255,7 @@ fn run_here(
|
||||
// One document script that throws (calling a method PdfCraft lacks, say) must not keep
|
||||
// every field script in the form from running: what it defined before the error, and
|
||||
// the other scripts, stay available (#3940).
|
||||
for &(i, d) in doc_scripts {
|
||||
for &(i, d) in setup.doc_scripts {
|
||||
if let Err(e) = ctx.eval(Source::from_bytes(d)) {
|
||||
doc_errors.push((i, doc_error(i, &e)));
|
||||
}
|
||||
@@ -248,6 +281,7 @@ fn run_here(
|
||||
if let Some(h) = ctx.remove_data::<Shared>() {
|
||||
let h = h.into_inner();
|
||||
out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect();
|
||||
out.formatted = h.formatted.iter().filter_map(|&i| h.fields.get(i).map(|f| f.name.clone())).collect();
|
||||
out.alerts = h.alerts;
|
||||
out.console = h.console;
|
||||
out.requests = h.requests;
|
||||
|
||||
+181
-2
@@ -19,7 +19,7 @@ fn fields() -> Vec<FieldState> {
|
||||
|
||||
/// The document these tests' scripts run in.
|
||||
fn doc() -> DocInfo {
|
||||
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], highlight: false, xfa: false }
|
||||
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], ..Default::default() }
|
||||
}
|
||||
|
||||
/// Run AcroForm JavaScript `script` for `event` on these tests' document and fields.
|
||||
@@ -27,6 +27,59 @@ fn go(script: &str, event: &Event) -> Outcome {
|
||||
run(script, event, &doc(), &fields(), &[], Limits::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_now_reports_errors_and_continues() {
|
||||
let calculations =
|
||||
vec![("bad".into(), "getField('price').value = 'partial'; throw new Error('broken');".into()), ("good".into(), "event.value = 'ok';".into())];
|
||||
let state = Arc::new(vec![
|
||||
FieldState::new("bad", FieldType::Text, vec![]),
|
||||
FieldState::new("good", FieldType::Text, vec![]),
|
||||
FieldState::new("price", FieldType::Text, vec![]),
|
||||
]);
|
||||
let outcome = run_shared_calculations(
|
||||
"calculateNow(); console.println('continued');",
|
||||
&Event::doc("Console"),
|
||||
&doc(),
|
||||
state,
|
||||
&[],
|
||||
&calculations,
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(outcome.error, None);
|
||||
assert!(outcome.console.iter().any(|line| line.contains("calculation script for bad failed: Error: broken")), "{outcome:?}");
|
||||
assert!(outcome.console.iter().any(|line| line == "continued"), "{outcome:?}");
|
||||
assert_eq!(outcome.changed.iter().find(|field| field.name == "good").unwrap().value, ["ok"]);
|
||||
assert!(outcome.changed.iter().all(|field| field.name != "price"), "{outcome:?}");
|
||||
assert_eq!(outcome.formatted, BTreeSet::from(["good".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_now_sets_read_only_fields_and_handles_hundreds() {
|
||||
let mut states = Vec::new();
|
||||
let mut calculations = Vec::new();
|
||||
for index in 0..300 {
|
||||
let name = format!("field{index}");
|
||||
let mut state = FieldState::new(name.clone(), FieldType::Text, vec![]);
|
||||
state.readonly = true;
|
||||
states.push(state);
|
||||
calculations.push((name, "event.value = 'calculated';".into()));
|
||||
}
|
||||
let state = Arc::new(states);
|
||||
let outcome =
|
||||
run_shared_calculations("calculateNow();", &Event::doc("Console"), &doc(), Arc::clone(&state), &[], &calculations, Limits::default());
|
||||
assert_eq!(outcome.error, None, "{outcome:?}");
|
||||
assert_eq!(outcome.changed.len(), 300);
|
||||
assert!(outcome.changed.iter().all(|field| field.value == ["calculated"] && field.readonly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caught_calculate_now_recursion_has_a_step_limit() {
|
||||
let calculations: Vec<_> = (0..600).map(|index| (format!("loop{index}"), "try { calculateNow(); } catch (e) {}".into())).collect();
|
||||
let state = Arc::new(calculations.iter().map(|(name, _)| FieldState::new(name.clone(), FieldType::Text, vec![])).collect());
|
||||
let outcome = run_shared_calculations("calculateNow();", &Event::doc("Console"), &doc(), state, &[], &calculations, Limits::default());
|
||||
assert!(outcome.error.as_deref().is_some_and(|error| error.contains("calculateNow evaluation limit exceeded")), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_scripts_read_fields_and_set_the_value() {
|
||||
let o = go("event.value = this.getField('price').value * getField('qty').value;", &Event::field("Calculate", "total", ""));
|
||||
@@ -50,6 +103,49 @@ fn the_outcome_says_whether_the_script_assigned_event_value() {
|
||||
assert_eq!(o.result.as_deref(), Some("true"), "value stays an enumerable property of event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_page_box_returns_supported_boxes_in_rotated_user_space() {
|
||||
let mut document = doc();
|
||||
document.page_boxes = vec![
|
||||
[
|
||||
[10.0, 20.0, 210.0, 320.0],
|
||||
[20.0, 30.0, 200.0, 310.0],
|
||||
[25.0, 35.0, 195.0, 305.0],
|
||||
[30.0, 40.0, 190.0, 300.0],
|
||||
[35.0, 45.0, 185.0, 295.0]
|
||||
];
|
||||
3
|
||||
];
|
||||
document.page_rotations = vec![0, 90, 270];
|
||||
let out = run(
|
||||
"JSON.stringify([this.getPageBox(), this.getPageBox('Media', 1), this.getPageBox('Bleed', 0), this.getPageBox('Trim', 0), this.getPageBox('Art', 2)])",
|
||||
&Event::doc("Console"),
|
||||
&document,
|
||||
&[],
|
||||
&[],
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(out.error, None);
|
||||
assert_eq!(out.result.as_deref(), Some("[[0,280,180,0],[-10,190,290,-10],[5,275,175,5],[10,270,170,10],[15,165,265,15]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_page_box_rejects_unknown_boxes_and_invalid_pages() {
|
||||
for script in [
|
||||
"this.getPageBox('BBox')",
|
||||
"this.getPageBox(1)",
|
||||
"this.getPageBox('Crop', -1)",
|
||||
"this.getPageBox('Crop', 1.5)",
|
||||
"this.getPageBox('Crop', 3)",
|
||||
"this.getPageBox('Crop', null)",
|
||||
"this.getPageBox('Crop', true)",
|
||||
"this.getPageBox('Crop', '0')",
|
||||
] {
|
||||
let out = go(script, &Event::doc("Console"));
|
||||
assert!(out.error.is_some(), "{script}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_with_leading_zeros_stay_text() {
|
||||
let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", ""));
|
||||
@@ -392,10 +488,14 @@ fn app_alert_titles_are_clipped_and_charged() {
|
||||
let mut h = Host {
|
||||
fields: Arc::new(Vec::new()),
|
||||
changed: BTreeSet::new(),
|
||||
formatted: BTreeSet::new(),
|
||||
alerts: Vec::new(),
|
||||
console: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
doc: doc(),
|
||||
calculations: Arc::new(Vec::new()),
|
||||
calculate_depth: 0,
|
||||
calculate_steps: 0,
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
@@ -1637,7 +1737,21 @@ $host.gotoURL("https://example.org")"#,
|
||||
Limits { loop_iterations: u64::MAX, recursion: 32 },
|
||||
Some(std::time::Duration::ZERO),
|
||||
);
|
||||
assert!(o.error.unwrap().contains("too long"));
|
||||
assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}");
|
||||
// Stopped by its clock, a script keeps none of its changes, as when the caller stops
|
||||
// waiting first: the outcome is the same whichever side notices. A loop adding rows
|
||||
// until the clock stops it kept up to 1000 new rows when the script noticed first.
|
||||
let o = crate::formcalc::run_formcalc_at(
|
||||
"while (1) do table._row.addInstance(1) endwhile",
|
||||
&ev,
|
||||
&doc,
|
||||
std::sync::Arc::new(form()),
|
||||
false,
|
||||
Limits { loop_iterations: u64::MAX, recursion: 32 },
|
||||
Some(std::time::Duration::from_millis(50)),
|
||||
);
|
||||
assert!(o.abandoned && o.effects.is_empty() && o.notes.is_empty(), "{:?}, {} effects", o.error, o.effects.len());
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{:?}", o.error);
|
||||
assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some());
|
||||
assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long"));
|
||||
assert!(run_hostile_formcalc("Space(1e12)").error.is_none());
|
||||
@@ -1885,6 +1999,16 @@ mod session {
|
||||
slow.timeout = std::time::Duration::from_millis(200);
|
||||
let o = s.run(slow);
|
||||
assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}");
|
||||
// Whether the script's clock or the session's wait notices first, a FormCalc loop
|
||||
// adding rows until the time is up is abandoned with none of its rows, and the session
|
||||
// is dead after it.
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
let mut slow = script("while (1) do table._row.addInstance(1) endwhile", true, "form1[0].page1[0].go[0]", "click");
|
||||
slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 };
|
||||
slow.timeout = std::time::Duration::from_millis(200);
|
||||
let o = s.run(slow);
|
||||
assert!(o.abandoned && o.effects.is_empty(), "{:?}, {} effects", o.error, o.effects.len());
|
||||
assert!(s.dead().is_some_and(|w| w.contains("abandoned")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1924,6 +2048,61 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() {
|
||||
assert!(read.changed.is_empty());
|
||||
}
|
||||
|
||||
mod data_objects {
|
||||
use super::*;
|
||||
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
|
||||
|
||||
/// `exportDataObject`, as a document method or a bare global, asks to open the named attached
|
||||
/// file when `nLaunch` is 1 or 2 and to save it otherwise; without `cName` it is an error.
|
||||
#[test]
|
||||
fn export_data_object_asks_to_open_or_save_an_attached_file() {
|
||||
let o = go(
|
||||
"this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); exportDataObject('notes.txt'); \
|
||||
this.exportDataObject({cName: 'form.pdf', nLaunch: 1});",
|
||||
&Event::field("Mouse Up", "total", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(
|
||||
o.requests,
|
||||
[
|
||||
Request::OpenAttachment { name: "Guide.pdf".into(), open: true },
|
||||
Request::OpenAttachment { name: "notes.txt".into(), open: false },
|
||||
Request::OpenAttachment { name: "form.pdf".into(), open: true },
|
||||
]
|
||||
);
|
||||
let o = go("this.exportDataObject({nLaunch: 2})", &Event::field("Mouse Up", "total", ""));
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("cName")), "{:?}", o.error);
|
||||
assert!(o.requests.is_empty());
|
||||
}
|
||||
|
||||
/// Only `nLaunch` 1 and 2 open the file: other values (1.5, 3, a huge number) save it, as the
|
||||
/// default 0 does.
|
||||
#[test]
|
||||
fn export_data_object_opens_only_for_n_launch_1_and_2() {
|
||||
for launch in ["1.5", "3", "1e9", "-1"] {
|
||||
let o = go(&format!("this.exportDataObject({{cName: 'Guide.pdf', nLaunch: {launch}}});"), &Event::field("Mouse Up", "total", ""));
|
||||
assert_eq!(o.requests, [Request::OpenAttachment { name: "Guide.pdf".into(), open: false }], "nLaunch {launch}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In an XFA script `event.target` is the document, so `exportDataObject` works on it, while
|
||||
/// `xfa.event.target` is still the node the event happened on.
|
||||
#[test]
|
||||
fn in_xfa_scripts_event_target_is_the_document_and_xfa_event_target_the_node() {
|
||||
let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
|
||||
let o = run_xfa(
|
||||
"event.target.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); xfa.event.target.name",
|
||||
&event,
|
||||
&XfaDoc::default(),
|
||||
&super::xfa_model::form(),
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("go"), "xfa.event.target is still the button");
|
||||
assert!(o.effects.contains(&XfaEffect::OpenAttachment { name: "Guide.pdf".into(), open: true }), "{:?}", o.effects);
|
||||
}
|
||||
}
|
||||
|
||||
mod runtime_highlight {
|
||||
use super::*;
|
||||
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa};
|
||||
|
||||
@@ -15,6 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy};
|
||||
use boa_engine::property::Attribute;
|
||||
use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
|
||||
use crate::globals::data_object;
|
||||
use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text};
|
||||
|
||||
mod globals;
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::instances::instance_manager;
|
||||
use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names};
|
||||
use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink};
|
||||
use super::{
|
||||
Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, platform, printd, printf, printx,
|
||||
s, scand, text,
|
||||
Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, data_object, function, js_string, platform, printd,
|
||||
printf, printx, s, scand, text,
|
||||
};
|
||||
|
||||
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
|
||||
@@ -15,6 +15,13 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `event.target.exportDataObject(…)`: the document asks to open or save an attached file.
|
||||
fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let (name, open) = data_object(args, ctx)?;
|
||||
effect(ctx, XfaEffect::OpenAttachment { name, open })?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `app.runtimeHighlight`: whether the viewer highlights form fields.
|
||||
fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.highlight))
|
||||
@@ -67,13 +74,13 @@ fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.beep()`: asks to beep.
|
||||
/// `xfa.host.beep()` and `app.beep()`: ask to beep.
|
||||
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Beep)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.gotoURL(url)`: asks to open the URL.
|
||||
/// `xfa.host.gotoURL(url)` and `app.launchURL(url)`: ask to open the URL.
|
||||
fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let u = text(&arg(args, 0), ctx)?;
|
||||
effect(ctx, XfaEffect::LaunchUrl(u))?;
|
||||
@@ -114,50 +121,63 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `recalculate(…)` on a node or on `xfa`: records a request to recalculate the form.
|
||||
pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Recalculate)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.layout.relayout()`, `relayoutPageArea()` and `remerge()`: record a request to lay the
|
||||
/// form out again.
|
||||
pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Relayout)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `console.println(…)`: adds the line, cut to [`MAX_LINE`] characters, to the run's console
|
||||
/// output.
|
||||
fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = clip(text(&arg(args, 0), ctx)?);
|
||||
host(ctx)?.borrow_mut().print(m);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.response(…)` and `app.response(…)`: always return `null`, as for a canceled dialog.
|
||||
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::null())
|
||||
}
|
||||
|
||||
/// `xfa.layout.page(…)`: the current page, 1-based, whatever the argument.
|
||||
fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0))
|
||||
}
|
||||
|
||||
/// `xfa.layout.absPage(…)`: the current page, 0-based, whatever the argument.
|
||||
fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64))
|
||||
}
|
||||
|
||||
/// `xfa.layout.pageCount()` and `absPageCount()`: the document's page count.
|
||||
fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64))
|
||||
}
|
||||
|
||||
/// The `xfa.layout` measures `h`, `w`, `x`, `y` and `pageSpan`: always 0.
|
||||
fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(0.0))
|
||||
}
|
||||
|
||||
/// `xfa.resolveNode(som)`, or `xfa.resolveNodes(som)` when `many`, resolving from the node the
|
||||
/// script runs on.
|
||||
fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult<JsValue> {
|
||||
let current = host(ctx)?.borrow().current;
|
||||
resolver(ctx, current, many)
|
||||
}
|
||||
|
||||
/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`,
|
||||
/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`,
|
||||
/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager.
|
||||
/// `resolveNode` and more; `xfa.event`'s target is that node), a separate global `event` whose
|
||||
/// target is a document offering `exportDataObject`, `app`, `console`, `util`, and the nodes in
|
||||
/// scope by name, with `_name` for each repeatable one's instance manager.
|
||||
pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> {
|
||||
let rw = Attribute::CONFIGURABLE;
|
||||
let form = node_object(ctx, 0)?;
|
||||
@@ -207,24 +227,33 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
.function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1)
|
||||
.property(js_string!("ready"), JsValue::from(true), Attribute::READONLY)
|
||||
.build();
|
||||
let ev = ObjectInitializer::new(ctx)
|
||||
.property(js_string!("name"), s(&event.activity), Attribute::all())
|
||||
.property(js_string!("newText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("prevText"), s(&event.prev_text), Attribute::all())
|
||||
.property(js_string!("fullText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("change"), s(""), Attribute::all())
|
||||
.property(js_string!("target"), this_node.clone(), Attribute::all())
|
||||
.property(js_string!("selStart"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("cancelAction"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("reenter"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("shift"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("modifier"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("keyDown"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all())
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0)
|
||||
.build();
|
||||
// Two event objects, as in Acrobat's XFA scripting: `xfa.event`, whose target is the form
|
||||
// node the event happened on, and Acrobat's own `event`, whose target is the document
|
||||
// (`event.target.exportDataObject(…)` is how forms open their attached files).
|
||||
let acrobat_doc =
|
||||
ObjectInitializer::new(ctx).function(NativeFunction::from_fn_ptr(export_data_object), js_string!("exportDataObject"), 4).build();
|
||||
let event_object = |ctx: &mut Context, target: JsValue| {
|
||||
ObjectInitializer::new(ctx)
|
||||
.property(js_string!("name"), s(&event.activity), Attribute::all())
|
||||
.property(js_string!("newText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("prevText"), s(&event.prev_text), Attribute::all())
|
||||
.property(js_string!("fullText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("change"), s(""), Attribute::all())
|
||||
.property(js_string!("target"), target, Attribute::all())
|
||||
.property(js_string!("selStart"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("cancelAction"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("reenter"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("shift"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("modifier"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("keyDown"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all())
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0)
|
||||
.build()
|
||||
};
|
||||
let ev = event_object(ctx, this_node.clone());
|
||||
let acrobat_ev = event_object(ctx, acrobat_doc.into());
|
||||
let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build();
|
||||
let template = sink(ctx)?;
|
||||
let resolve_one = xfa_resolve(ctx, false)?;
|
||||
@@ -243,7 +272,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
.function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0)
|
||||
.build();
|
||||
ctx.register_global_property(js_string!("xfa"), xfa, rw)?;
|
||||
ctx.register_global_property(js_string!("event"), ev, rw)?;
|
||||
ctx.register_global_property(js_string!("event"), acrobat_ev, rw)?;
|
||||
ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?;
|
||||
let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight));
|
||||
let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight));
|
||||
|
||||
@@ -104,6 +104,11 @@ pub enum XfaEffect {
|
||||
MessageBox(Message),
|
||||
/// `xfa.host.resetData()`: the named fields, or every field when empty.
|
||||
ResetData(Vec<String>),
|
||||
/// `event.target.exportDataObject(…)`: the attached file `name`, to open or to save.
|
||||
OpenAttachment {
|
||||
name: String,
|
||||
open: bool,
|
||||
},
|
||||
/// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
|
||||
ShowPanel(crate::Panel),
|
||||
Print,
|
||||
@@ -217,6 +222,7 @@ pub(super) fn payload(e: &XfaEffect) -> usize {
|
||||
som.len().saturating_add(s.len())
|
||||
}
|
||||
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(),
|
||||
XfaEffect::OpenAttachment { name, .. } => name.len(),
|
||||
XfaEffect::MessageBox(m) => m.bytes(),
|
||||
XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
|
||||
@@ -179,7 +179,15 @@ mod native {
|
||||
loop {
|
||||
let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now()));
|
||||
match worker.from.recv_timeout(left) {
|
||||
Ok(Reply::Outcome(o)) => return *o,
|
||||
Ok(Reply::Outcome(o)) => {
|
||||
// A FormCalc script its own clock stopped reports as abandoned: the
|
||||
// session ends as it does when the wait runs out first.
|
||||
if o.abandoned {
|
||||
self.dead = o.error.clone();
|
||||
self.worker = None;
|
||||
}
|
||||
return *o;
|
||||
}
|
||||
Ok(Reply::Started) => {
|
||||
// A second call changes nothing: the script's time has already begun.
|
||||
if !running {
|
||||
|
||||
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user