mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
Merge remote-tracking branch 'upstream/main' into fix/page-render-warnings
# Conflicts: # crates/automation/tests/it/main.rs
This commit is contained in:
@@ -21,3 +21,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -27,8 +27,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: pdfcraft
|
||||
app-dir: PdfCraft
|
||||
install-relative-path: Programs/ArtCraft/PdfCraft
|
||||
install-scope: perUser
|
||||
|
||||
+2
-2
@@ -335,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
|
||||
| `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) |
|
||||
| `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
|
||||
| `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
|
||||
| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
|
||||
| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
|
||||
| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
|
||||
| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
|
||||
|
||||
## Optional build inputs (1)
|
||||
|
||||
|
||||
+8
-8
@@ -3356,30 +3356,30 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa
|
||||
sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
|
||||
|
||||
[[fetched]]
|
||||
file = "text-detection.rten"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"
|
||||
file = "text-detection.onnx"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx"
|
||||
title = "Ocrs text detection model"
|
||||
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
|
||||
licence = "CC-BY-SA-4.0"
|
||||
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
|
||||
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
kind = "model"
|
||||
usage = "Scan & OCR: finds words on page images (cargo xtask models)"
|
||||
sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca"
|
||||
sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe"
|
||||
|
||||
[[fetched]]
|
||||
file = "text-recognition.rten"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"
|
||||
file = "text-recognition.onnx"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx"
|
||||
title = "Ocrs text recognition model"
|
||||
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
|
||||
licence = "CC-BY-SA-4.0"
|
||||
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
|
||||
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
kind = "model"
|
||||
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
|
||||
sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
|
||||
sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
|
||||
|
||||
[[build_input]]
|
||||
name = "craft-fonts"
|
||||
|
||||
@@ -22,6 +22,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
|
||||
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
|
||||
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
|
||||
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
|
||||
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
|
||||
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
|
||||
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
|
||||
@@ -39,6 +40,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
|
||||
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
|
||||
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
|
||||
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
|
||||
- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search "<terms>"` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor <commit> origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
|
||||
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
|
||||
|
||||
Generated
+242
-52
@@ -1023,6 +1023,15 @@ dependencies = [
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -1169,12 +1178,40 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -1184,7 +1221,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-menu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-keymap",
|
||||
"craft-quit",
|
||||
@@ -1197,11 +1234,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree 0.21.2",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winsafe",
|
||||
@@ -1210,7 +1247,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
@@ -1221,20 +1258,28 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -2073,6 +2118,17 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2188,7 +2244,7 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2882,6 +2938,23 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
@@ -2890,11 +2963,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
@@ -2907,6 +3001,7 @@ dependencies = [
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
@@ -2917,6 +3012,32 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
@@ -2946,6 +3067,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2953,6 +3077,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -3251,6 +3399,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -4054,7 +4205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4078,7 +4229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4090,7 +4241,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4101,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4112,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4134,7 +4285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4152,11 +4303,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4164,7 +4315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4179,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4187,6 +4338,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
@@ -4195,7 +4347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4207,7 +4359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4222,7 +4374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4263,14 +4415,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4280,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4290,7 +4442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4303,11 +4455,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4315,7 +4467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4328,24 +4480,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
"rten",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4360,11 +4513,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4375,7 +4528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4385,18 +4538,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4408,7 +4561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4424,7 +4577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4443,7 +4596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4479,11 +4632,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
@@ -4535,7 +4688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4547,27 +4700,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"png 0.18.1",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -4888,12 +5041,24 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5444,6 +5609,15 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.10.0-rc.18"
|
||||
@@ -5473,6 +5647,7 @@ dependencies = [
|
||||
"rten-base",
|
||||
"rten-gemm",
|
||||
"rten-model-file",
|
||||
"rten-onnx",
|
||||
"rten-parallel",
|
||||
"rten-shape-inference",
|
||||
"rten-simd",
|
||||
@@ -5522,6 +5697,12 @@ dependencies = [
|
||||
"rten-base",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rten-onnx"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e"
|
||||
|
||||
[[package]]
|
||||
name = "rten-parallel"
|
||||
version = "0.26.0"
|
||||
@@ -6515,7 +6696,10 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6710,6 +6894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -7078,7 +7263,7 @@ dependencies = [
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
@@ -7166,8 +7351,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo 0.1.1",
|
||||
@@ -8052,6 +8235,9 @@ name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -8376,8 +8562,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8386,6 +8575,7 @@ version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+45
-41
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,51 +13,51 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false }
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", default-features = false }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.14.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.14.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.14.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.14.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.14.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.14.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.14.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.15.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.15.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.15.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.15.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.15.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.15.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.15.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.15.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.15.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.15.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.15.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.15.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.15.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.15.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.15.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.15.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.15.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.15.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.15.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.15.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.15.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
@@ -91,6 +91,9 @@ web-time = "1.1"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
@@ -125,6 +128,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
vello_cpu = { path = "vendor/vello_cpu" }
|
||||
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
|
||||
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
|
||||
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
|
||||
|
||||
@@ -459,11 +459,13 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt`
|
||||
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
|
||||
`%APPDATA%`; delete that file to use the normal per-user folders.
|
||||
|
||||
The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft"
|
||||
The MSI installs for your account only by default, without administrator privileges. Choose
|
||||
"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install
|
||||
for all users. It adds "Edit with PdfCraft"
|
||||
to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
|
||||
images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
|
||||
"Show more options"), without changing which app opens them by default. For unattended deployment
|
||||
without a desktop shortcut, run from an elevated terminal:
|
||||
without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install):
|
||||
|
||||
```powershell
|
||||
msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0
|
||||
@@ -471,7 +473,7 @@ msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU
|
||||
|
||||
| Property | `=0` leaves out |
|
||||
|---|---|
|
||||
| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut |
|
||||
| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut |
|
||||
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
|
||||
| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**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: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
@@ -199,6 +200,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **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-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
@@ -682,6 +684,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-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. |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::commands::{parse_dpi, read};
|
||||
use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line,
|
||||
};
|
||||
|
||||
/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
|
||||
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::options::{
|
||||
COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional,
|
||||
validate_options,
|
||||
};
|
||||
use super::*;
|
||||
use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
|
||||
|
||||
pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
|
||||
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::CliError;
|
||||
|
||||
pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#[cfg(feature = "mcp")]
|
||||
use super::options::MCP_OPTIONS;
|
||||
use super::options::{RUN_OPTIONS, flag, positional, validate_options};
|
||||
use super::*;
|
||||
#[cfg(feature = "mcp")]
|
||||
use super::{Arc, Path, Write, automation_args};
|
||||
use super::{CliError, stdout_line};
|
||||
|
||||
// ---- automation --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::options::{UI_OPTIONS, flag, positional, validate_options};
|
||||
use super::*;
|
||||
use super::{CliError, Duration, stdout_line};
|
||||
|
||||
// ---- UI control channel client -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -243,4 +243,40 @@ mod page_argument_tests {
|
||||
assert_eq!(std::fs::read(&source).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_clips_glyphs_outside_the_crop_box() {
|
||||
let dir = PageDir::new();
|
||||
let path = dir.0.join("cropped.pdf");
|
||||
let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET";
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(&path, pdf).unwrap();
|
||||
|
||||
for args in [vec![], vec!["--page", "1"]] {
|
||||
let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {out:?}");
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,8 @@ disallowed-methods = [
|
||||
{ path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
|
||||
{ path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
|
||||
@@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
// Where each id's last glyph ended, in user space.
|
||||
let mut ends: HashMap<i64, (f64, f64)> = HashMap::new();
|
||||
let mut gs = TextState::default();
|
||||
let mut saved: Vec<TextState> = Vec::new();
|
||||
// With each saved state, the text and text line matrices: inside a text object `q`/`Q`
|
||||
// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
|
||||
// errata, issue 368), as the renderer does.
|
||||
let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new();
|
||||
let mut unsaved = 0usize;
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
|
||||
@@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()),
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)),
|
||||
b"q" => unsaved += 1,
|
||||
b"Q" if unsaved > 0 => unsaved -= 1,
|
||||
b"Q" => {
|
||||
if let Some(saved) = saved.pop() {
|
||||
gs = saved;
|
||||
(gs, tm, tlm) = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as
|
||||
/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose
|
||||
/// glyphs continue after a `Q` stays one word.
|
||||
#[test]
|
||||
fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
let doc = heading_page(&[
|
||||
"BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(),
|
||||
"BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(),
|
||||
]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_has_no_headings() {
|
||||
let mut objs = headings_doc();
|
||||
|
||||
@@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten
|
||||
add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?;
|
||||
set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?;
|
||||
move_annotation(&mut doc, page, index, dx, dy, &meta)?;
|
||||
set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?;
|
||||
set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?;
|
||||
delete_annotation(&mut doc, page, index)?;
|
||||
signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials
|
||||
```
|
||||
|
||||
+606
-506
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
|
||||
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
|
||||
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
|
||||
|
||||
+52
-38
@@ -1,8 +1,10 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::modify::{n, rich_text};
|
||||
use super::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style};
|
||||
use super::*;
|
||||
use super::types::{
|
||||
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
||||
};
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
pub(crate) fn num_array(v: &[f64]) -> Object {
|
||||
Object::Array(v.iter().map(|x| Object::Real(*x)).collect())
|
||||
@@ -203,16 +205,12 @@ pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
|
||||
pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result<usize, AnnotError> {
|
||||
let page = page_ref(doc, new.page)?;
|
||||
let style = &new.style;
|
||||
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
validate_style(style)?;
|
||||
let rect = rect_for(&new.shape, style)?;
|
||||
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
|
||||
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
|
||||
let opacity = style.opacity.clamp(0.0, 1.0);
|
||||
if opacity < 1.0 {
|
||||
d.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
}
|
||||
set_non_opaque_style(&mut d, opacity);
|
||||
let border = |d: &mut Dict| {
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
|
||||
@@ -232,24 +230,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
|
||||
}
|
||||
Shape::Redact { quads, overlay, look } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
|
||||
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
|
||||
if !overlay.is_empty() {
|
||||
d.set(b"OverlayText".to_vec(), PdfString::text(overlay));
|
||||
let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
|
||||
let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
|
||||
d.set(
|
||||
b"DA".to_vec(),
|
||||
PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()),
|
||||
);
|
||||
d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
|
||||
if look.repeat {
|
||||
d.set(b"Repeat".to_vec(), Object::Bool(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look),
|
||||
Shape::Rectangle { .. } | Shape::Oval { .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
if let Some(f) = style.fill {
|
||||
@@ -426,22 +407,55 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let mut list = annots(doc, page);
|
||||
let index = list.len();
|
||||
list.push(Object::Ref(r));
|
||||
if let Some(pr) = popup {
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
p.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
p.set(b"Rect".to_vec(), num_array(&pr));
|
||||
p.set(b"Parent".to_vec(), Object::Ref(r));
|
||||
p.set(b"Open".to_vec(), Object::Bool(false));
|
||||
p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
let pref = doc.add(Object::Dict(p));
|
||||
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
|
||||
list.push(Object::Ref(pref));
|
||||
}
|
||||
add_popup(doc, r, popup, &mut list)?;
|
||||
set_annots(doc, page, list)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn validate_style(style: &Style) -> Result<(), AnnotError> {
|
||||
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) {
|
||||
dict.set(b"C".to_vec(), rgb(style.color));
|
||||
dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
|
||||
dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
|
||||
if !overlay.is_empty() {
|
||||
dict.set(b"OverlayText".to_vec(), PdfString::text(overlay));
|
||||
let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
|
||||
let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
|
||||
dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()));
|
||||
dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
|
||||
if look.repeat {
|
||||
dict.set(b"Repeat".to_vec(), Object::Bool(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
|
||||
if opacity < 1.0 {
|
||||
dict.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
|
||||
let Some(rect) = rect else { return Ok(()) };
|
||||
let mut popup = Dict::new();
|
||||
popup.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
popup.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
popup.set(b"Rect".to_vec(), num_array(&rect));
|
||||
popup.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
popup.set(b"Open".to_vec(), Object::Bool(false));
|
||||
popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
let popup_ref = doc.add(Object::Dict(popup));
|
||||
doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?;
|
||||
annots.push(Object::Ref(popup_ref));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
|
||||
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
|
||||
/// box is fitted to `/Rect` by PDF viewers.
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::create::{finite, num_array};
|
||||
use super::lookup::{annot_dict, annots, page_refs};
|
||||
use super::modify::refuse_foreign_text;
|
||||
use super::types::NOTE_SIZE;
|
||||
use super::*;
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance};
|
||||
|
||||
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its
|
||||
/// text (a restyle, a new size or colour).
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::draw::set_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
|
||||
use super::modify::{fillable, touch, unlocked};
|
||||
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||||
use super::*;
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ pub use modify::{
|
||||
};
|
||||
pub use types::{
|
||||
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
|
||||
StampGroup, StampKind, Style, rect_quad,
|
||||
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
|
||||
};
|
||||
|
||||
pub mod appearance;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::info::text_value;
|
||||
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
|
||||
use super::*;
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, appearance};
|
||||
|
||||
// ── pages and /Annots ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ use super::info::{line_endings_of, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
|
||||
use super::types::{
|
||||
FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style,
|
||||
StyleChange, TextReplacement,
|
||||
};
|
||||
use super::*;
|
||||
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
|
||||
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
|
||||
@@ -499,17 +500,8 @@ fn border_width_of(d: &Dict) -> f64 {
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_style(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
index: usize,
|
||||
color: Option<Rgb>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
endings: Option<&[LineEnding]>,
|
||||
meta: &Meta,
|
||||
) -> Result<(), AnnotError> {
|
||||
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let StyleChange { color, opacity, width, endings } = change;
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
@@ -521,7 +513,7 @@ pub fn set_style(
|
||||
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
if let Some(ends) = &endings {
|
||||
let callout = subtype == "FreeText" && d.contains(b"CL");
|
||||
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
|
||||
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
|
||||
@@ -691,18 +683,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
|
||||
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
|
||||
/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
|
||||
/// from the caller, so the edit stays deterministic.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn add_text_replacement(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
quads: &[[f64; 8]],
|
||||
replacement: &str,
|
||||
author: &str,
|
||||
strike: &Style,
|
||||
caret: &Style,
|
||||
meta: &Meta,
|
||||
caret_meta: &Meta,
|
||||
) -> Result<usize, AnnotError> {
|
||||
pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result<usize, AnnotError> {
|
||||
let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement;
|
||||
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
|
||||
let s = add_annotation(
|
||||
doc,
|
||||
|
||||
+45
-14
@@ -303,7 +303,7 @@ fn move_shifts_all_geometry_and_the_popup() {
|
||||
fn restyle_and_resize_redraw_the_appearance() {
|
||||
let mut doc = fixture();
|
||||
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
|
||||
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap();
|
||||
set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[r];
|
||||
@@ -313,7 +313,10 @@ fn restyle_and_resize_redraw_the_appearance() {
|
||||
assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5));
|
||||
// A stamp's appearance can't be regenerated, so restyling it changes nothing.
|
||||
let before = list(&doc, 1)[0].clone();
|
||||
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
|
||||
assert_eq!(
|
||||
set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")),
|
||||
Err(AnnotError::Unsupported("Stamp".into()))
|
||||
);
|
||||
assert_eq!(list(&doc, 1)[0], before);
|
||||
// Lines can't be resized as rectangles.
|
||||
let l = add_annotation(
|
||||
@@ -374,7 +377,7 @@ fn text_box_text_and_colour_changes_are_drawn() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%");
|
||||
@@ -556,8 +559,8 @@ fn fill_and_sign_items_are_drawn() {
|
||||
assert_eq!(text(&all[s], b"Subj"), "Signature");
|
||||
// Other stamps still can't be restyled; ours can.
|
||||
let mut doc = doc;
|
||||
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
|
||||
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err());
|
||||
set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
|
||||
assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -844,7 +847,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
|
||||
let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap();
|
||||
let i = add_text_replacement(
|
||||
&mut doc,
|
||||
TextReplacement {
|
||||
page: 0,
|
||||
quads: &quads,
|
||||
replacement: "new words",
|
||||
author: "Ada",
|
||||
strike: &red,
|
||||
caret: &blue,
|
||||
strike_meta: &meta("r"),
|
||||
caret_meta: &meta("r-caret"),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let a = list(&doc, 0);
|
||||
let strike = &a[i];
|
||||
@@ -866,7 +882,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let mut doc = doc;
|
||||
delete_annotation(&mut doc, 0, i).unwrap();
|
||||
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
|
||||
assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err());
|
||||
assert!(
|
||||
add_text_replacement(
|
||||
&mut doc,
|
||||
TextReplacement {
|
||||
page: 0,
|
||||
quads: &[],
|
||||
replacement: "x",
|
||||
author: "Ada",
|
||||
strike: &red,
|
||||
caret: &blue,
|
||||
strike_meta: &meta("r"),
|
||||
caret_meta: &meta("r-caret")
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1177,7 +1208,7 @@ fn every_line_ending_is_drawn() {
|
||||
)
|
||||
.unwrap();
|
||||
let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"));
|
||||
set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap();
|
||||
set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
|
||||
let after = rect(&line);
|
||||
@@ -1245,7 +1276,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() {
|
||||
let other_dict = list(&doc, 0)[other].clone();
|
||||
let source = other_dict.get(b"AP").unwrap();
|
||||
let before = doc.resolve(source);
|
||||
set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
|
||||
assert_eq!(doc.resolve(source), before, "a shared AP must not change");
|
||||
for full in [false, true] {
|
||||
let bytes = if full {
|
||||
@@ -1367,7 +1398,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
|
||||
.unwrap();
|
||||
assert_eq!(mark(&doc, s), Some('张'));
|
||||
// Our own `?` appearance is redrawn on a restyle; Western European text clears the mark.
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
|
||||
assert_eq!(mark(&doc, t), Some('签'));
|
||||
set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap();
|
||||
assert_eq!(mark(&doc, t), None);
|
||||
@@ -1395,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
|
||||
set_annots(&mut doc, page, on_page).unwrap();
|
||||
let k = list(&doc, 0).len() - 1;
|
||||
let before = list(&doc, 0)[k].clone();
|
||||
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err();
|
||||
let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err();
|
||||
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}");
|
||||
assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance");
|
||||
let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err();
|
||||
@@ -1476,7 +1507,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() {
|
||||
fn text_box_font_size_changes_the_da_and_grows_the_box() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
|
||||
set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap();
|
||||
let height = |doc: &Document| {
|
||||
let rect: Vec<f64> = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
@@ -1522,7 +1553,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() {
|
||||
let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
|
||||
assert!(props(&doc, 0, index).unwrap().fillable);
|
||||
set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
|
||||
set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
|
||||
set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap();
|
||||
let saved = reopen(&doc);
|
||||
let properties = props(&saved, 0, index).unwrap();
|
||||
assert_eq!(properties.fill, Some([1.0; 3]));
|
||||
@@ -1791,7 +1822,7 @@ fn a_star_note_keeps_its_star_when_recoloured() {
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>",
|
||||
]);
|
||||
assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star));
|
||||
set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let d = &list(&doc, 0)[0];
|
||||
assert_eq!(d.name(b"Name"), Some(&b"Star"[..]));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::create::normalize;
|
||||
use super::*;
|
||||
use super::{ObjRef, appearance};
|
||||
|
||||
pub type Rgb = [f64; 3];
|
||||
|
||||
@@ -563,6 +563,33 @@ pub struct Style {
|
||||
pub fill: Option<Rgb>,
|
||||
}
|
||||
|
||||
/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StyleChange {
|
||||
pub color: Option<Rgb>,
|
||||
pub opacity: Option<f64>,
|
||||
pub width: Option<f64>,
|
||||
pub endings: Option<Vec<LineEnding>>,
|
||||
}
|
||||
|
||||
impl StyleChange {
|
||||
pub fn new(color: Option<Rgb>, opacity: Option<f64>, width: Option<f64>, endings: Option<Vec<LineEnding>>) -> Self {
|
||||
Self { color, opacity, width, endings }
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs for replacing selected text with a grouped strikeout and caret annotation.
|
||||
pub struct TextReplacement<'a> {
|
||||
pub page: usize,
|
||||
pub quads: &'a [[f64; 8]],
|
||||
pub replacement: &'a str,
|
||||
pub author: &'a str,
|
||||
pub strike: &'a Style,
|
||||
pub caret: &'a Style,
|
||||
pub strike_meta: &'a Meta,
|
||||
pub caret_meta: &'a Meta,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
|
||||
|
||||
@@ -112,13 +112,13 @@ The release packages ship `pdfcraft-cli` alongside the desktop app, so no build
|
||||
|
||||
| Install | CLI |
|
||||
|---|---|
|
||||
| Windows (MSI) | `C:\Program Files\PdfCraft\pdfcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
|
||||
| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe` by default (`C:\Program Files\ArtCraft\PdfCraft\pdfcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` |
|
||||
| Linux (deb, rpm) | `/usr/bin/pdfcraft-cli` |
|
||||
| macOS | the separate `pdfcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
|
||||
|
||||
```sh
|
||||
# Windows, default install folder
|
||||
claude mcp add pdfcraft -- "C:\Program Files\PdfCraft\pdfcraft-cli.exe" mcp --root <dir>
|
||||
# Windows (PowerShell), default install folder
|
||||
claude mcp add pdfcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe" mcp --root <dir>
|
||||
# Linux, or macOS with the CLI unzipped onto PATH
|
||||
claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic};
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic};
|
||||
|
||||
/// A script's request to the viewer as the JSON that running a script reports; a side panel
|
||||
/// goes by its fixed id.
|
||||
@@ -17,14 +17,21 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
|
||||
/// icon and buttons.
|
||||
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
|
||||
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// `js_run`: run `script` in the document, or as button `field`'s script, and report its
|
||||
/// messages, console lines, requests, error, the failing document scripts' errors and result.
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
let alerts: Vec<Value> = o.alerts.iter().map(alert_json).collect();
|
||||
Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }))
|
||||
}
|
||||
|
||||
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
|
||||
|
||||
impl Automation {
|
||||
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, one_based, summary};
|
||||
use super::paths::{child, read_input, read_input_bytes, unused, write_atomic};
|
||||
use super::*;
|
||||
use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json};
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn doc_create(&mut self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
|
||||
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels};
|
||||
use super::paths::{read_input, write_atomic};
|
||||
use super::*;
|
||||
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
|
||||
|
||||
/// One piece of a tool's result.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -234,6 +234,34 @@ impl Automation {
|
||||
out["next"] = listing["next"].clone();
|
||||
out
|
||||
}
|
||||
"text_styles" => {
|
||||
// The samples are the document's text, as text_lines is.
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let styles: Vec<Value> = doc
|
||||
.text_styles()
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
json!({
|
||||
"font": u.style.font,
|
||||
"size": u.style.size,
|
||||
"bold": u.style.bold,
|
||||
"italic": u.style.italic,
|
||||
"lines": u.lines,
|
||||
"pages": u.pages,
|
||||
"sample": u.sample,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "styles": styles })
|
||||
}
|
||||
"bookmark_from_text_styles" => {
|
||||
let levels = text_style_levels(&a)?;
|
||||
let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?;
|
||||
let listing = self.bookmark_listing(&a)?;
|
||||
out["bookmarks"] = listing["bookmarks"].clone();
|
||||
out["next"] = listing["next"].clone();
|
||||
out
|
||||
}
|
||||
"doc_protect" => self.doc_protect(&a)?,
|
||||
"page_replace" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
@@ -742,7 +770,7 @@ impl Automation {
|
||||
let ids: Vec<Value> = pdfcraft_engine::sign::keychain::identities(None)
|
||||
.map_err(failed)?
|
||||
.iter()
|
||||
.map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
|
||||
.map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) }))
|
||||
.collect();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let ids: Vec<Value> = Vec::new();
|
||||
@@ -755,7 +783,7 @@ impl Automation {
|
||||
let ids = listing
|
||||
.ids
|
||||
.iter()
|
||||
.map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
|
||||
.map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) }))
|
||||
.collect();
|
||||
let unusable = listing
|
||||
.unusable
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, nest_rows, security, summary};
|
||||
use super::paths::{child, read_input, write_atomic};
|
||||
use super::*;
|
||||
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json};
|
||||
|
||||
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
|
||||
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
|
||||
@@ -539,11 +539,23 @@ impl Automation {
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
let mut lowered = Vec::new();
|
||||
for p in pages {
|
||||
let img = ex.image(p, dpi, format).map_err(failed)?;
|
||||
// A write error stops the export as it is, not as a page error.
|
||||
let mut write_error = None;
|
||||
let exported = ex.images(&pages, dpi, format, |p, img| {
|
||||
let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension()));
|
||||
write_atomic(&path, &img)?;
|
||||
write_atomic(&path, &img).map_err(|e| {
|
||||
let message = e.to_string();
|
||||
write_error = Some(e);
|
||||
message
|
||||
})?;
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
});
|
||||
if let Some(e) = write_error {
|
||||
return Err(e);
|
||||
}
|
||||
exported.map_err(failed)?;
|
||||
for &p in &pages {
|
||||
// A page too large for the renderer at `dpi` is drawn at the most it allows.
|
||||
let used = ex.dpi_used(p, dpi);
|
||||
if used < dpi.clamp(18.0, 1200.0) - 0.5 {
|
||||
|
||||
@@ -600,17 +600,23 @@ impl Automation {
|
||||
};
|
||||
let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?;
|
||||
out["tab_order"] = self.tab_sequence(a)?;
|
||||
out["page_tabs"] = self.page_tabs(a)?;
|
||||
return Ok(out);
|
||||
}
|
||||
let order = match a.str("order")? {
|
||||
"row" | "rows" => pdfcraft_engine::TabOrder::Row,
|
||||
"column" | "columns" => pdfcraft_engine::TabOrder::Column,
|
||||
"structure" => pdfcraft_engine::TabOrder::Structure,
|
||||
"annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations,
|
||||
o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
|
||||
let order = match a.opt_str("order")? {
|
||||
None => None,
|
||||
Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row),
|
||||
Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column),
|
||||
Some("structure") => Some(pdfcraft_engine::TabOrder::Structure),
|
||||
Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations),
|
||||
Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
|
||||
};
|
||||
let mut out = match order {
|
||||
Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?,
|
||||
None => json!({}),
|
||||
};
|
||||
let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?;
|
||||
out["tab_order"] = self.tab_sequence(a)?;
|
||||
out["page_tabs"] = self.page_tabs(a)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -671,12 +677,35 @@ impl Automation {
|
||||
Ok(json!(seq.iter().map(|x| x.1).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Each page's tab order (Page properties ▸ Tab order), in page order.
|
||||
fn page_tabs(&self, a: &Args) -> Result<Value> {
|
||||
let names: Vec<&str> = self
|
||||
.doc(a)?
|
||||
.tab_orders()
|
||||
.into_iter()
|
||||
.map(|o| match o {
|
||||
pdfcraft_engine::TabOrder::Row => "row",
|
||||
pdfcraft_engine::TabOrder::Column => "column",
|
||||
pdfcraft_engine::TabOrder::Structure => "structure",
|
||||
pdfcraft_engine::TabOrder::Annotations => "annotations",
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(names))
|
||||
}
|
||||
|
||||
pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result<Value> {
|
||||
let name = a.str("field")?.to_owned();
|
||||
if !self.doc(a)?.form.iter().any(|f| f.name == name) {
|
||||
return Err(failed(format!("there is no field named {name:?} (see form_fields)")));
|
||||
}
|
||||
self.apply(a, Edit::DeleteField { name })
|
||||
let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) };
|
||||
let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len());
|
||||
let widget = usize::try_from(widget)
|
||||
.ok()
|
||||
.and_then(|w| w.checked_sub(1))
|
||||
.filter(|w| *w < count)
|
||||
.ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?;
|
||||
self.apply(a, Edit::DeleteWidget { name, widget })
|
||||
}
|
||||
|
||||
pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::error::{Result, ToolError};
|
||||
use super::*;
|
||||
use super::{Document, Value, comments, json};
|
||||
|
||||
/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at
|
||||
/// most 14 400 across).
|
||||
@@ -7,6 +7,36 @@ const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
|
||||
|
||||
// ---- JSON helpers ----------------------------------------------------------------------------
|
||||
|
||||
/// The most bookmark levels `bookmark_from_text_styles` takes.
|
||||
const MAX_STYLE_LEVELS: usize = 9;
|
||||
|
||||
/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each
|
||||
/// `{ font, size, bold, italic }` as text_styles lists them.
|
||||
pub(crate) fn text_style_levels(a: &Args) -> Result<Vec<pdfcraft_engine::text_style_bookmarks::TextStyle>> {
|
||||
let levels =
|
||||
a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?;
|
||||
if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS {
|
||||
return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles")));
|
||||
}
|
||||
levels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}"));
|
||||
let font =
|
||||
v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?;
|
||||
let size =
|
||||
v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?;
|
||||
let flag = |key: &str| match v.get(key) {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(_) => Err(bad(&format!("{key} must be true or false"))),
|
||||
};
|
||||
Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
|
||||
let Some(value) = value else { return Ok(Vec::new()) };
|
||||
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::dispatch::Content;
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, one_based};
|
||||
use super::*;
|
||||
use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools};
|
||||
|
||||
/// Default and maximum resolution for `page_render`.
|
||||
pub(crate) const DEFAULT_DPI: f64 = 96.0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::error::{Result, failed};
|
||||
use super::*;
|
||||
use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes};
|
||||
|
||||
impl Automation {
|
||||
// ---- paths -------------------------------------------------------------------------------
|
||||
|
||||
@@ -347,6 +347,39 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
"text_styles",
|
||||
"List text styles",
|
||||
"The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
"bookmark_from_text_styles",
|
||||
"New bookmarks from text styles",
|
||||
"Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"levels": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 9,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": { "type": "string", "minLength": 1 },
|
||||
"size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"bold": { "type": "boolean" },
|
||||
"italic": { "type": "boolean" }
|
||||
},
|
||||
"required": ["font", "size"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
&["doc", "levels"],
|
||||
)),
|
||||
t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
@@ -464,7 +497,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
|
||||
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
&["doc"],
|
||||
@@ -478,11 +511,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])),
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])),
|
||||
t(
|
||||
"redact_mark",
|
||||
"Mark for redaction",
|
||||
@@ -1135,7 +1168,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
|
||||
.cmd("form.merge_data")
|
||||
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
|
||||
@@ -516,6 +516,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() {
|
||||
assert_eq!(result["status"], "passed");
|
||||
}
|
||||
|
||||
/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18,
|
||||
/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693).
|
||||
fn styled_headings_pdf() -> Vec<u8> {
|
||||
let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \
|
||||
BT /FB 14 Tf 72 640 Td (Background) Tj ET";
|
||||
let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()),
|
||||
format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the
|
||||
/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why
|
||||
/// when it can't.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_through_tools() {
|
||||
let dir = workdir("text-style-bookmarks");
|
||||
std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
|
||||
let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone();
|
||||
assert_eq!(
|
||||
styles,
|
||||
json!([
|
||||
{ "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" },
|
||||
{ "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" },
|
||||
{ "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" },
|
||||
])
|
||||
);
|
||||
// The styles as text_styles gave them, the chapters first.
|
||||
let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]);
|
||||
let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels }));
|
||||
let top = &r["bookmarks"][0];
|
||||
assert_eq!(top["title"], "Untitled");
|
||||
let titles: Vec<(&str, u64)> =
|
||||
top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect();
|
||||
assert_eq!(titles, [("Introduction", 1), ("Results", 2)]);
|
||||
assert_eq!(top["children"][0]["children"][0]["title"], "Background");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles");
|
||||
// What can't be done says why.
|
||||
let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels }));
|
||||
assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size")));
|
||||
assert!(
|
||||
matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold"))
|
||||
);
|
||||
let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }]));
|
||||
assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}");
|
||||
let too_many: Vec<Value> = (0..10).map(|_| styles[0].clone()).collect();
|
||||
assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// A one-page PDF whose form holds one text field named `name`.
|
||||
fn form_pdf(name: &str) -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
@@ -2030,21 +2102,33 @@ fn comments_through_tools() {
|
||||
let dir = workdir("comments");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let id = add_comment_examples(&mut a, doc);
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
let rect = verify_comment_listing(&list, &id);
|
||||
edit_rectangle_comment(&mut a, doc, &rect);
|
||||
edit_text_comment(&mut a, doc, &list);
|
||||
verify_comment_errors(&mut a, doc, &id);
|
||||
save_and_reopen_comments(&dir, &mut a, doc);
|
||||
}
|
||||
|
||||
fn add_comment_examples(a: &mut Automation, doc: u64) -> String {
|
||||
// "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page.
|
||||
let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
|
||||
let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
|
||||
assert_eq!(hl["comment"]["lines"], 1);
|
||||
let id = hl["comment"]["id"].as_str().unwrap().to_string();
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] }));
|
||||
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" }));
|
||||
ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" }));
|
||||
ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" }));
|
||||
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 }));
|
||||
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 }));
|
||||
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] }));
|
||||
ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] }));
|
||||
ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" }));
|
||||
ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" }));
|
||||
id
|
||||
}
|
||||
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
fn verify_comment_listing(list: &Value, id: &str) -> Value {
|
||||
assert_eq!(list["count"], 6, "{list}");
|
||||
let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone();
|
||||
let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
|
||||
assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check")));
|
||||
assert_eq!(c["status"], "Accepted");
|
||||
assert_eq!(c["replies"].as_array().unwrap().len(), 1);
|
||||
@@ -2057,33 +2141,38 @@ fn comments_through_tools() {
|
||||
assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0]));
|
||||
let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone();
|
||||
assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point");
|
||||
rect
|
||||
}
|
||||
|
||||
fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) {
|
||||
// Edit by page + index; several changes are one undo step.
|
||||
let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap());
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap());
|
||||
ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" }));
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone();
|
||||
assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved")));
|
||||
assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0]));
|
||||
let undo = ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
let undo = ok(a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(undo["undone"], "Edit comment");
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
ok(a, "edit_redo", json!({ "doc": doc }));
|
||||
// Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none.
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
|
||||
ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
|
||||
ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
|
||||
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_))));
|
||||
let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
|
||||
let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
|
||||
assert!(matches!(
|
||||
a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })),
|
||||
Err(ToolError::Failed(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) {
|
||||
// Editing a text box re-fits its rectangle to the new text: the wrap width and top edge
|
||||
// stay, the height follows the wrapped lines.
|
||||
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
|
||||
let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
|
||||
let long = "the quick brown fox jumps over the lazy dog ".repeat(3);
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
|
||||
let r: Vec<f64> = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}");
|
||||
@@ -2092,9 +2181,11 @@ fn comments_through_tools() {
|
||||
// The rendered page shows the rectangle's border.
|
||||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
assert!(matches!(png[0], Content::Png { .. }));
|
||||
}
|
||||
|
||||
ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id }));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5);
|
||||
fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) {
|
||||
ok(a, "comment_delete", json!({ "doc": doc, "id": id }));
|
||||
assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5);
|
||||
assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_))));
|
||||
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_))));
|
||||
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_))));
|
||||
@@ -2103,10 +2194,11 @@ fn comments_through_tools() {
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
// Comments survive a save and reopen.
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" }));
|
||||
let mut b = auto(&dir);
|
||||
fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) {
|
||||
ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" }));
|
||||
let mut b = auto(dir);
|
||||
let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5);
|
||||
}
|
||||
@@ -2548,6 +2640,29 @@ fn orphan_form_fields_through_tools() {
|
||||
assert_ne!(v, "Filled by an agent");
|
||||
}
|
||||
|
||||
/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat
|
||||
/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text.
|
||||
#[test]
|
||||
fn auto_sized_text_follows_a_tall_field() {
|
||||
let objs: Vec<&str> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6
|
||||
];
|
||||
let dir = workdir("auto-size-tall");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } }));
|
||||
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" }));
|
||||
let r = &found["matches"][0]["rects"][0];
|
||||
let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap();
|
||||
assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_pages_through_tools() {
|
||||
let dir = workdir("reverse");
|
||||
@@ -2809,6 +2924,12 @@ fn accessibility_check_report_and_fixes_through_tools() {
|
||||
let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap();
|
||||
assert!(manual_html.contains("<p>Needs manual check: 1</p>"));
|
||||
assert!(!manual_html.contains("no problems"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessibility_figure_tags_and_alternative_text_through_tools() {
|
||||
let dir = workdir("a11y-figures");
|
||||
let mut a = auto(&dir);
|
||||
// Figures: list, describe, mark decorative.
|
||||
std::fs::write(
|
||||
dir.join("figures.pdf"),
|
||||
@@ -4004,6 +4125,29 @@ fn preparing_a_form_through_tools() {
|
||||
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() {
|
||||
// #4949: two buttons placed separately are two groups; naming one after the other makes
|
||||
// them one group, and a clashing export value is renamed instead of shared.
|
||||
let dir = workdir("radio-join");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" }));
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } }));
|
||||
let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" }));
|
||||
assert_eq!(r["field"], "size");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let f = fields["fields"].as_array().unwrap();
|
||||
assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["size"], "one group");
|
||||
assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value");
|
||||
assert_eq!(f[0]["value"], "S", "the group keeps its selection");
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } }));
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_a_field_through_tools() {
|
||||
let dir = workdir("rotate-field");
|
||||
@@ -4914,6 +5058,23 @@ fn the_calculation_order_is_read_fixed_and_set() {
|
||||
assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_tab_orders_are_read_and_set_per_page_through_tools() {
|
||||
let dir = workdir("pagetabs");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc }));
|
||||
assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"]));
|
||||
assert!(r["undo"].is_null(), "reading changes nothing: {r}");
|
||||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] }));
|
||||
assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"]));
|
||||
assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true)));
|
||||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] }));
|
||||
assert_eq!(r["page_tabs"], json!(["column", "structure", "column"]));
|
||||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] }));
|
||||
assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_order_and_field_appearance_through_tools() {
|
||||
let dir = workdir("taborder");
|
||||
@@ -5260,6 +5421,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
|
||||
|
||||
#[test]
|
||||
fn local_gotor_link_open_applies_destinations_and_honors_root() {
|
||||
let root = local_gotor_fixtures();
|
||||
let mut a = auto(&root);
|
||||
assert_relative_gotor_destination(&mut a);
|
||||
assert_absolute_gotor_policy(&mut a);
|
||||
assert_unapplied_gotor_destinations(&mut a);
|
||||
assert_encrypted_gotor_passwords(&mut a);
|
||||
assert_unsupported_gotor_targets(&mut a);
|
||||
assert_gotor_root_boundaries(&mut a);
|
||||
assert_url_links_cannot_open_as_gotor(&mut a);
|
||||
}
|
||||
|
||||
fn local_gotor_fixtures() -> PathBuf {
|
||||
let (base, root) = sandbox("gotor-open");
|
||||
std::fs::create_dir_all(root.join("sub")).unwrap();
|
||||
std::fs::write(root.join("target.pdf"), fixture(3)).unwrap();
|
||||
@@ -5277,12 +5450,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
|
||||
std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap();
|
||||
link_dir(&root, "outside-link", &base.join("outside"));
|
||||
std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
let mut a = auto(&root);
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let listed = ok(&mut a, "link_list", json!({ "doc": source }));
|
||||
fn assert_relative_gotor_destination(a: &mut Automation) {
|
||||
let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let listed = ok(a, "link_list", json!({ "doc": source }));
|
||||
assert_eq!(listed["links"][0]["other"], "GoToR");
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
assert_eq!(opened["path"], "target.pdf");
|
||||
assert_eq!(opened["destination_applied"], true);
|
||||
assert_eq!(opened["destination"]["page"], 3);
|
||||
@@ -5290,51 +5465,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
|
||||
assert_eq!(opened["document"]["pages"], 3);
|
||||
let target_id = opened["document"]["doc"].as_u64().unwrap();
|
||||
assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3);
|
||||
}
|
||||
|
||||
let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
|
||||
fn assert_absolute_gotor_policy(a: &mut Automation) {
|
||||
let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
|
||||
let before = a.session().docs().len();
|
||||
for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] {
|
||||
assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
|
||||
assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
|
||||
assert_eq!(a.session().docs().len(), before);
|
||||
}
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
|
||||
let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
|
||||
assert_eq!(opened["path"], "target.pdf");
|
||||
assert_eq!(opened["destination"]["page"], 2);
|
||||
}
|
||||
|
||||
fn assert_unapplied_gotor_destinations(a: &mut Automation) {
|
||||
for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] {
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
assert_eq!(opened["destination_applied"], false);
|
||||
assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}");
|
||||
}
|
||||
}
|
||||
|
||||
let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
|
||||
fn assert_encrypted_gotor_passwords(a: &mut Automation) {
|
||||
let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||||
assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||||
assert_eq!(a.session().docs().len(), documents_before);
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
|
||||
assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
|
||||
assert_eq!(a.session().docs().len(), documents_before);
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
|
||||
let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
|
||||
assert_eq!(opened["document"]["encrypted"], true);
|
||||
}
|
||||
|
||||
fn assert_unsupported_gotor_targets(a: &mut Automation) {
|
||||
for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] {
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||||
assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||||
assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_gotor_root_boundaries(a: &mut Automation) {
|
||||
for source_path in ["sub/escape.pdf", "symlink.pdf"] {
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
|
||||
assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
|
||||
assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened");
|
||||
}
|
||||
}
|
||||
|
||||
let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
|
||||
fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) {
|
||||
let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
|
||||
assert!(
|
||||
matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
|
||||
matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5887,8 +6074,14 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
|
||||
fn digital_ids_signing_and_validation_through_tools() {
|
||||
let dir = workdir("signing");
|
||||
let mut a = auto(&dir);
|
||||
create_signing_identity(&mut a);
|
||||
let doc = sign_document_and_verify_trust(&mut a, &dir);
|
||||
verify_signature_revisions_and_bundle(&mut a, doc, &dir);
|
||||
}
|
||||
|
||||
fn create_signing_identity(a: &mut Automation) {
|
||||
let id = ok(
|
||||
&mut a,
|
||||
a,
|
||||
"sign_id_create",
|
||||
json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }),
|
||||
);
|
||||
@@ -5897,13 +6090,15 @@ fn digital_ids_signing_and_validation_through_tools() {
|
||||
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
|
||||
// Eight characters at least (#1847): seven is refused.
|
||||
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0);
|
||||
fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 {
|
||||
let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0);
|
||||
// A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere).
|
||||
assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_))));
|
||||
assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_))));
|
||||
let store = ok(&mut a, "sign_windows_ids", json!({}));
|
||||
let store = ok(a, "sign_windows_ids", json!({}));
|
||||
let ids = store["ids"].as_array().unwrap();
|
||||
assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64);
|
||||
assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:")));
|
||||
@@ -5917,7 +6112,7 @@ fn digital_ids_signing_and_validation_through_tools() {
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
let r = ok(
|
||||
&mut a,
|
||||
a,
|
||||
"sign_document",
|
||||
json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }),
|
||||
);
|
||||
@@ -5929,33 +6124,37 @@ fn digital_ids_signing_and_validation_through_tools() {
|
||||
assert!(dir.join("signed.pdf").exists());
|
||||
|
||||
// Trust the ID: valid; comment afterwards: still valid, change allowed.
|
||||
let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
|
||||
let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
|
||||
assert_eq!(t["trusted"].as_array().unwrap().len(), 1);
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
|
||||
let list = ok(a, "sign_list", json!({ "doc": doc }));
|
||||
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")));
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
|
||||
let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0];
|
||||
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
|
||||
let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0];
|
||||
assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed")));
|
||||
doc
|
||||
}
|
||||
|
||||
fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) {
|
||||
// A full rewrite is refused for a signed document.
|
||||
assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_))));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc }));
|
||||
ok(a, "doc_save", json!({ "doc": doc }));
|
||||
// Three revisions: the original, the signature, the comment; the middle one is signed.
|
||||
let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap();
|
||||
let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap();
|
||||
assert_eq!(revs.len(), 3, "{revs:?}");
|
||||
assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}");
|
||||
let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment");
|
||||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1);
|
||||
let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment");
|
||||
assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1);
|
||||
assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_))));
|
||||
assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
|
||||
assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
|
||||
// The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one.
|
||||
use pdfcraft_engine::sign::{cms, der, pkcs12};
|
||||
let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw;
|
||||
let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]);
|
||||
std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap();
|
||||
assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid");
|
||||
assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid");
|
||||
}
|
||||
|
||||
/// The decoded text of every visible signature appearance (Helvetica forms) in a signed file.
|
||||
@@ -6449,6 +6648,8 @@ fn ocr_recognize_files_with_distinct_names_runs_as_before() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Field scripts set through the tools validate, calculate and format, and `js_run` reports console
|
||||
/// lines, every message with its details, and requests.
|
||||
#[test]
|
||||
fn javascript_through_tools() {
|
||||
let dir = workdir("js");
|
||||
@@ -6482,10 +6683,17 @@ fn javascript_through_tools() {
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }),
|
||||
json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok'); app.alert({ cMsg: 'Clear?', cTitle: 'Order', nIcon: 2, nType: 3 });" }),
|
||||
);
|
||||
assert_eq!(r["console"][0], "2500");
|
||||
assert_eq!(r["alerts"][0], "ok");
|
||||
// Every message, in order, with its title, icon and buttons.
|
||||
assert_eq!(
|
||||
r["alerts"],
|
||||
json!([
|
||||
{ "message": "ok", "title": null, "icon": "error", "buttons": "ok" },
|
||||
{ "message": "Clear?", "title": "Order", "icon": "question", "buttons": "yes_no_cancel" },
|
||||
])
|
||||
);
|
||||
assert_eq!(r["requests"][0]["page"], 2);
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
|
||||
@@ -7406,6 +7614,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
check(&mut a, doc);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo,
|
||||
/// and `js_run` reports their messages.
|
||||
#[test]
|
||||
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
let dir = workdir("xfa-scripts");
|
||||
@@ -7434,7 +7644,7 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert!(field(&mut a, "amount_2").is_none());
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
|
||||
assert_eq!(r["alerts"], json!(["Hello 4"]));
|
||||
assert_eq!(r["alerts"], json!([{ "message": "Hello 4", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7447,7 +7657,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
|
||||
ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Console message"]));
|
||||
assert_eq!(out["alerts"], json!([{ "message": "Console message", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
|
||||
ok(&mut a, "doc_close", json!({"doc":doc}));
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
@@ -7457,7 +7667,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
|
||||
assert_eq!(out["alerts"], json!([]));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Hello 2"]));
|
||||
assert_eq!(out["alerts"], json!([{ "message": "Hello 2", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
ok(&mut a, "xfa_to_static", json!({"doc":doc}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""}));
|
||||
assert_eq!(out["alerts"].as_array().unwrap().len(), 1);
|
||||
@@ -7881,7 +8091,7 @@ fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
|
||||
let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)";
|
||||
let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"});
|
||||
let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 190, 40], "size": 7, "align": "right"});
|
||||
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
|
||||
assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9"));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0);
|
||||
@@ -9175,3 +9385,75 @@ fn page_render_draws_stamps_without_an_appearance() {
|
||||
let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count();
|
||||
assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_radio_button_through_tools_keeps_the_group() {
|
||||
let dir = workdir("delete-radio");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (x, export) in [(20, "S"), (40, "M"), (60, "L")] {
|
||||
let args = json!({ "doc": doc, "page": 1, "type": "radio", "rect": [x, 100, x + 14, 114], "group": "size", "export": export });
|
||||
ok(&mut a, "form_add_field", args);
|
||||
}
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "L" } }));
|
||||
ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "size", "widget": 2 }));
|
||||
let size = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"][0].clone();
|
||||
assert_eq!((size(&mut a)["options"].clone(), size(&mut a)["value"].clone()), (json!(["S", "L"]), json!("L")), "only M is gone");
|
||||
for widget in [0, 3, -1] {
|
||||
let r = a.call("form_delete_field", &json!({ "doc": doc, "field": "size", "widget": widget }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "widget {widget}: {r:?}");
|
||||
}
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back");
|
||||
}
|
||||
/// `/I` of the top-level field `name` in the saved file `path`.
|
||||
fn saved_selection_indices(path: &Path, name: &str) -> Option<Vec<i64>> {
|
||||
let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
|
||||
let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
|
||||
let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
|
||||
let f = all
|
||||
.iter()
|
||||
.filter_map(|o| d.resolve(o).as_dict().cloned())
|
||||
.find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name))
|
||||
.unwrap();
|
||||
f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() {
|
||||
let dir = workdir("choice-index");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" });
|
||||
ok(&mut a, "form_add_field", args);
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } }));
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" }));
|
||||
assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1");
|
||||
}
|
||||
|
||||
/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none,
|
||||
/// even when its parent field has one it would otherwise inherit.
|
||||
#[test]
|
||||
fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
|
||||
"<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
];
|
||||
let dir = workdir("kid-default");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "default": "" }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } }));
|
||||
ok(&mut a, "form_reset", json!({ "doc": doc }));
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
|
||||
assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
|
||||
}
|
||||
|
||||
@@ -8,4 +8,6 @@ mod button_caption;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
mod optimize_content_boundaries;
|
||||
mod redact_after_stray_q_form;
|
||||
mod redact_text_after_q;
|
||||
mod render_warnings;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes
|
||||
//! come from the renderer's text and redaction measures the content itself, so the two must agree
|
||||
//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68).
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \
|
||||
BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q";
|
||||
const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q";
|
||||
|
||||
fn pdf() -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||||
},
|
||||
Err(e) => panic!("{tool} {args}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1);
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET"), "{after:?}");
|
||||
assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text
|
||||
//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so
|
||||
//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the
|
||||
//! renderer's text, which places it there; redaction measures the content itself, and while it
|
||||
//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything.
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The content, the text that stays after SECRET is redacted, and the text that goes with it.
|
||||
const CASES: [(&str, &str, &str); 3] = [
|
||||
// A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over
|
||||
// the B and the B goes with it.
|
||||
("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"),
|
||||
// A move inside `q`/`Q` is undone.
|
||||
("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"),
|
||||
// The text line matrix is restored too: `T*` starts the next line from the line before `q`.
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"),
|
||||
];
|
||||
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut out = b"%PDF-2.0\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||||
},
|
||||
Err(e) => panic!("{tool} {args}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() {
|
||||
for (case, (content, kept, gone)) in CASES.iter().enumerate() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}");
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}");
|
||||
assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::reader::find;
|
||||
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes};
|
||||
use super::xref::{Revision, XrefEntry};
|
||||
use super::*;
|
||||
use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared};
|
||||
|
||||
impl Document {
|
||||
pub fn bytes(&self) -> &Arc<Vec<u8>> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::reader::{find, generated_id};
|
||||
use super::state::Credentials;
|
||||
use super::xref::XrefIndex;
|
||||
use super::*;
|
||||
use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object};
|
||||
|
||||
impl Document {
|
||||
/// A new document with an empty page tree (for split / extract / combine). It has no
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::state::SECTION_READS;
|
||||
use super::xref::{
|
||||
MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData,
|
||||
};
|
||||
use super::*;
|
||||
use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared};
|
||||
|
||||
impl Document {
|
||||
#[allow(clippy::type_complexity)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::xref::XrefEntry;
|
||||
use super::*;
|
||||
use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak};
|
||||
|
||||
thread_local! {
|
||||
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, BTreeMap, Lexer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum XrefEntry {
|
||||
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
png = "0.18"
|
||||
tiff = "0.11"
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
|
||||
|
||||
@@ -562,7 +562,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, Creat
|
||||
} else if is_tiff(bytes) {
|
||||
tiff_pages(name, bytes, wanted)
|
||||
} else if bytes.starts_with(b"BM") {
|
||||
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
|
||||
Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?])
|
||||
} else if is_gif(bytes) {
|
||||
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
|
||||
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
|
||||
|
||||
@@ -206,6 +206,26 @@ fn bmp_gif_and_multi_page_tiff_images() {
|
||||
assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..))));
|
||||
}
|
||||
|
||||
/// #4937: a BMP whose header gives the pixel data's offset as 0 is placed as the same image as
|
||||
/// the intact file, not shifted by the header bytes.
|
||||
#[test]
|
||||
fn a_bmp_with_a_zero_pixel_offset_is_placed_like_the_intact_file() {
|
||||
use image::ImageEncoder;
|
||||
let mut bmp = Vec::new();
|
||||
let pixels: Vec<u8> = (0..18).map(|i| i * 13).collect();
|
||||
image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&pixels, 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let mut zero = bmp.clone();
|
||||
zero[10..14].copy_from_slice(&0u32.to_le_bytes());
|
||||
let samples = |data: Vec<u8>| {
|
||||
let doc = reopen(&from_images(&[("a.bmp".into(), data)]).unwrap());
|
||||
let res = doc.resolve(pages(&doc)[0].get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!("not an image") };
|
||||
s.decoded().unwrap()
|
||||
};
|
||||
assert_eq!(samples(zero), samples(bmp));
|
||||
}
|
||||
|
||||
/// #665 lifted the tiff crate's 256 MiB decode limit; a few bytes claiming a 60000x60000 page
|
||||
/// (3.6 GB of gray pixels) must still be refused, not allocated.
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::layout::{
|
||||
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
|
||||
};
|
||||
use super::types::{AddedText, Align, Family};
|
||||
use super::*;
|
||||
use super::{
|
||||
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
|
||||
literal, matrix_number, n, shaped_glyph, win_ansi,
|
||||
};
|
||||
|
||||
fn font_name(base: &str) -> String {
|
||||
format!("PCF{}", base.replace('-', ""))
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::types::{AddedImage, AddedText};
|
||||
use super::*;
|
||||
use super::{
|
||||
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew,
|
||||
shape_thai,
|
||||
};
|
||||
|
||||
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::draw::{draw, params};
|
||||
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
|
||||
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
|
||||
use super::*;
|
||||
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged};
|
||||
|
||||
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
let r = nums(doc, d.get(b"Rect"));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::layout::norm;
|
||||
use super::*;
|
||||
use super::{Content, MarkKind, ObjRef, Std14};
|
||||
|
||||
pub(super) const TAG: &str = "Added";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::place::{
|
||||
END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place,
|
||||
place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role,
|
||||
};
|
||||
use super::*;
|
||||
use super::{Document, EditError, Object, added, helvetica_width};
|
||||
|
||||
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
|
||||
/// those pages are removed first (Acrobat's Replace Existing).
|
||||
|
||||
@@ -16,6 +16,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use crate::{EditError, check, n, page_list, place_tagged};
|
||||
|
||||
const HIDDEN: i64 = 2;
|
||||
const NO_ROTATE: i64 = 16;
|
||||
const NO_VIEW: i64 = 32;
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
|
||||
@@ -74,6 +75,28 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> {
|
||||
Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy])
|
||||
}
|
||||
|
||||
/// `m` followed by a turn of `degrees` (the page's /Rotate) about the upper-left corner of
|
||||
/// `rect`: a NoRotate annotation stays upright on a rotated page (ISO 32000-2 §12.5.3), its
|
||||
/// upper-left corner where it was, which is how MuPDF and Poppler draw it.
|
||||
fn upright(m: [f64; 6], degrees: i64, rect: [f64; 4]) -> [f64; 6] {
|
||||
let (sin, cos) = match degrees.rem_euclid(360) {
|
||||
90 => (1.0, 0.0),
|
||||
180 => (0.0, -1.0),
|
||||
270 => (-1.0, 0.0),
|
||||
_ => return m,
|
||||
};
|
||||
let (px, py) = (rect[0], rect[3]);
|
||||
let t = [cos, sin, -sin, cos, px - px * cos + py * sin, py - px * sin - py * cos];
|
||||
[
|
||||
m[0] * t[0] + m[1] * t[2],
|
||||
m[0] * t[1] + m[1] * t[3],
|
||||
m[2] * t[0] + m[3] * t[2],
|
||||
m[2] * t[1] + m[3] * t[3],
|
||||
m[4] * t[0] + m[5] * t[2] + t[4],
|
||||
m[4] * t[1] + m[5] * t[3] + t[5],
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Which {
|
||||
/// Every comment and/or every form field.
|
||||
@@ -194,6 +217,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
let mut gone_inline: HashSet<usize> = HashSet::new();
|
||||
// Selected, but nothing was drawn for it: it stays, pop-up and reply handling included.
|
||||
let mut undrawn: HashSet<ObjRef> = HashSet::new();
|
||||
let rotation = page.dict.get(b"Rotate").map(|r| doc.resolve(r)).and_then(|r| r.as_int()).unwrap_or(0);
|
||||
// A viewer draws many comments from their dictionary even when the file stores no
|
||||
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
|
||||
// flattening keeps a comment that still has ink on screen instead of deleting it.
|
||||
@@ -230,6 +254,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
|
||||
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
|
||||
if let Some(m) = placement(doc, &form, rect) {
|
||||
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
|
||||
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
|
||||
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Document, EditError, Object, Rgb, added};
|
||||
|
||||
/// The kinds of page marks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::marks::{MarkKind, matrix_number, n};
|
||||
use super::*;
|
||||
use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi};
|
||||
|
||||
pub(crate) fn page_list(doc: &Document) -> Vec<pdfcraft_model::Page> {
|
||||
pdfcraft_model::pages(doc)
|
||||
|
||||
+175
-75
@@ -507,6 +507,42 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
|
||||
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
|
||||
}
|
||||
|
||||
/// #2368: a widget with /NoRotate on a rotated page is shown upright, turned with the page's
|
||||
/// /Rotate about its rectangle's upper-left corner (§12.5.3, as MuPDF and Poppler draw it).
|
||||
/// Flattening drew its appearance at the rectangle as it is, so the widget turned sideways and
|
||||
/// moved; a neighbour without the flag is unchanged, and so is everything on an unrotated page.
|
||||
#[test]
|
||||
fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() {
|
||||
let page = stream("", " ");
|
||||
let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 15 >>\nstream\n0 0 100 50 re f\nendstream";
|
||||
for (rotate, plain, upright) in [
|
||||
(0, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 1 0 0 1 100 500 cm /PCFl1 Do Q"),
|
||||
(90, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 1 -1 0 150 550 cm /PCFl1 Do Q"),
|
||||
(180, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q -1 0 0 -1 100 600 cm /PCFl1 Do Q"),
|
||||
(270, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 -1 1 0 50 550 cm /PCFl1 Do Q"),
|
||||
] {
|
||||
let widget =
|
||||
|flags: i64| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{flags}) /Rect [100 500 200 550] /F {flags} /AP << /N 7 0 R >> >>");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << >> /Contents 6 0 R /Annots [4 0 R 5 0 R] >>"
|
||||
),
|
||||
&widget(4),
|
||||
&widget(4 | 16),
|
||||
&page,
|
||||
form,
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 2);
|
||||
let doc = reopen(&doc);
|
||||
let s = streams(&doc, 0);
|
||||
let flat = s.last().unwrap();
|
||||
assert!(flat.contains(plain), "/Rotate {rotate}: {flat}");
|
||||
assert!(flat.contains(upright), "/Rotate {rotate}: {flat}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
|
||||
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
|
||||
#[test]
|
||||
@@ -6664,12 +6700,124 @@ fn actual_text_pairs_past_the_marked_content_cap() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2992: the colour a page is painted with before anything else is its background, for exports
|
||||
/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes
|
||||
/// behind; a page that paints anything else first, a fill that doesn't cover the page, a
|
||||
/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none.
|
||||
fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (index, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
|
||||
}
|
||||
let xref_offset = bytes.len();
|
||||
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
|
||||
page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap()
|
||||
}
|
||||
|
||||
fn assert_page_background_fill_cases(gs: &str) {
|
||||
assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
|
||||
assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
|
||||
assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
|
||||
assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
|
||||
assert_eq!(
|
||||
page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""),
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
"one of the rectangles covers it"
|
||||
);
|
||||
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
|
||||
// other way under nonzero.
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
|
||||
assert_eq!(
|
||||
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""),
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
"nonzero, same winding: no hole"
|
||||
);
|
||||
assert_eq!(
|
||||
page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""),
|
||||
None,
|
||||
"reversed by a mirroring cm"
|
||||
);
|
||||
assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
|
||||
assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
|
||||
assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
|
||||
assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
|
||||
assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
|
||||
assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
|
||||
assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
|
||||
assert_eq!(
|
||||
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""),
|
||||
None,
|
||||
"a page fill with a hole cut in it"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_page_background_transform_and_clip_cases() {
|
||||
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
|
||||
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
|
||||
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
|
||||
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
|
||||
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
|
||||
assert_eq!(
|
||||
page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""),
|
||||
None,
|
||||
"a turned fill short of the corners"
|
||||
);
|
||||
assert_eq!(
|
||||
page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""),
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
"a turned fill over them"
|
||||
);
|
||||
// The same for a clip.
|
||||
assert_eq!(
|
||||
page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
|
||||
None,
|
||||
"a turned clip"
|
||||
);
|
||||
assert_eq!(
|
||||
page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
"a turned clip over the corners"
|
||||
);
|
||||
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
|
||||
// doesn't.
|
||||
assert_eq!(
|
||||
page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
"two covering clips"
|
||||
);
|
||||
assert_eq!(
|
||||
page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
|
||||
None,
|
||||
"the second clip doesn't cover"
|
||||
);
|
||||
// Past 64 clipping rectangles the clip counts as unknown.
|
||||
let clips = "0 0 600 800 re W n ".repeat(65);
|
||||
assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
|
||||
// A rectangle drawn the other way round (negative width) covers the page all the same.
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
|
||||
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
|
||||
// edge of that included.
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
|
||||
assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
|
||||
}
|
||||
|
||||
/// #2992: page background detection follows the first full-page fill under text and ignores
|
||||
/// partial, translucent, clipped, or unsupported fills.
|
||||
#[test]
|
||||
fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
|
||||
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
|
||||
let mut doc = fixture();
|
||||
assert_eq!(page_background(&doc, 0).unwrap(), None, "text first");
|
||||
add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap();
|
||||
@@ -6680,77 +6828,8 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
|
||||
assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content");
|
||||
assert!(page_background(&doc, 9).is_err());
|
||||
|
||||
let page = |content: &str, resources: &str| {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes());
|
||||
page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap()
|
||||
};
|
||||
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
|
||||
assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
|
||||
assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
|
||||
assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
|
||||
assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
|
||||
assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
|
||||
assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
|
||||
assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it");
|
||||
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
|
||||
// other way under nonzero.
|
||||
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
|
||||
assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
|
||||
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole");
|
||||
assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm");
|
||||
assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
|
||||
assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
|
||||
assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
|
||||
assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
|
||||
assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
|
||||
assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
|
||||
assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
|
||||
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it");
|
||||
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
|
||||
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
|
||||
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
|
||||
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
|
||||
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
|
||||
assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners");
|
||||
assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them");
|
||||
// The same for a clip.
|
||||
assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip");
|
||||
assert_eq!(
|
||||
page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
"a turned clip over the corners"
|
||||
);
|
||||
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
|
||||
// doesn't.
|
||||
assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips");
|
||||
assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover");
|
||||
// Past 64 clipping rectangles the clip counts as unknown.
|
||||
let clips = "0 0 600 800 re W n ".repeat(65);
|
||||
assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
|
||||
// A rectangle drawn the other way round (negative width) covers the page all the same.
|
||||
assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
|
||||
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
|
||||
// edge of that included.
|
||||
assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
|
||||
assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
|
||||
assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
|
||||
assert_page_background_fill_cases(gs);
|
||||
assert_page_background_transform_and_clip_cases();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7457,3 +7536,24 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
|
||||
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
|
||||
}
|
||||
|
||||
/// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2
|
||||
/// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the
|
||||
/// text after `Q` is found, and stays, where it is drawn.
|
||||
#[test]
|
||||
fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
for (content, text, baseline) in [
|
||||
("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]),
|
||||
("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]),
|
||||
// `T*` moves from the restored line, not the one set inside q/Q.
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]),
|
||||
] {
|
||||
let mut doc = text_page(content);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}");
|
||||
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::interp::{Carry, interpret};
|
||||
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
|
||||
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
|
||||
use super::*;
|
||||
use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions};
|
||||
|
||||
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
|
||||
/// A region may contain several source fonts, sizes, colours and weights.
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock};
|
||||
use super::fallback::MAX_TYPE3_GLYPHS;
|
||||
use super::lines::{page_dict, text_lines};
|
||||
use super::rewrite::rewrite_block_from;
|
||||
use super::*;
|
||||
use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession};
|
||||
|
||||
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
|
||||
/// are never installed, copied into assets, or used as a font for another document.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::interp::Ts;
|
||||
use super::lines::page_dict;
|
||||
use super::*;
|
||||
use super::{
|
||||
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
|
||||
};
|
||||
|
||||
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
|
||||
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
+291
-254
@@ -1,6 +1,6 @@
|
||||
use super::fallback::is_type3_fallback;
|
||||
use super::rewrite::vertical_advance;
|
||||
use super::*;
|
||||
use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc};
|
||||
|
||||
/// Text and graphics state captured for rewriting.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -425,7 +425,10 @@ fn positioned_tracking(shown: &mut [Shown]) {
|
||||
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
|
||||
pub(super) struct Carry {
|
||||
pub(super) ts: Ts,
|
||||
pub(super) stack: Vec<Ts>,
|
||||
/// Each saved state with the text and text line matrices: inside a text object `q`/`Q`
|
||||
/// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
|
||||
/// errata, issue 368), as the renderer does.
|
||||
pub(super) stack: Vec<(Ts, Matrix, Matrix)>,
|
||||
pub(super) marked: Vec<Option<usize>>,
|
||||
/// For each open marked-content sequence, its optional content group when it is `/OC`.
|
||||
pub(super) layers: Vec<Option<Vec<u8>>>,
|
||||
@@ -472,6 +475,280 @@ type CachedFont = (Rc<Metrics>, Option<String>, Result<Option<f64>, String>);
|
||||
/// A `Do` met while interpreting: its operator index, XObject name and the state there.
|
||||
type Invocation = (usize, Vec<u8>, Ts);
|
||||
|
||||
#[derive(Default)]
|
||||
struct MarkedContent {
|
||||
regions: Vec<Option<usize>>,
|
||||
layers: Vec<Option<Vec<u8>>>,
|
||||
actual_text: Vec<Option<(usize, usize, String)>>,
|
||||
}
|
||||
|
||||
impl MarkedContent {
|
||||
fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) {
|
||||
// Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and
|
||||
// inner spans, which inherit their enclosing region.
|
||||
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
|
||||
self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() });
|
||||
// A layer named in the resources is told apart by its name, an inline one by where it opens.
|
||||
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
|
||||
self.layers
|
||||
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
|
||||
// The property list is inline or named in the resources' /Properties (§14.6.2).
|
||||
let list = logical_text.and_then(|properties| match op.operands.get(1) {
|
||||
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
|
||||
other => other.and_then(Object::as_dict).cloned(),
|
||||
});
|
||||
let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value {
|
||||
Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()),
|
||||
_ => None,
|
||||
});
|
||||
self.actual_text.push(logical.map(|text| (shown_count, index, text)));
|
||||
}
|
||||
|
||||
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
|
||||
self.regions.pop();
|
||||
self.layers.pop();
|
||||
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
|
||||
let tail = shown.split_off(start.min(shown.len()));
|
||||
let mut items = tail.into_iter();
|
||||
if let Some(mut first) = items.next() {
|
||||
for item in items {
|
||||
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
|
||||
first.extra_ops.push(item.op);
|
||||
first.extra_ops.extend(item.extra_ops);
|
||||
first.rect = [
|
||||
first.rect[0].min(item.rect[0]),
|
||||
first.rect[1].min(item.rect[1]),
|
||||
first.rect[2].max(item.rect[2]),
|
||||
first.rect[3].max(item.rect[3]),
|
||||
];
|
||||
first.frame = union(first.frame, item.frame);
|
||||
first.ink.extend(item.ink);
|
||||
}
|
||||
first.extra_ops.extend([open, index]);
|
||||
// Only right-to-left ActualText is already in typing order and must stay out of
|
||||
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
|
||||
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
|
||||
first.actual_text = true;
|
||||
first.text = logical;
|
||||
first.start_x = first.rect[0];
|
||||
first.end_x = first.rect[2];
|
||||
first.end_y = first.baseline;
|
||||
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
|
||||
shown.push(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TextRunState<'a> {
|
||||
ts: &'a mut Ts,
|
||||
tm: &'a mut Matrix,
|
||||
tlm: &'a mut Matrix,
|
||||
at_bt: &'a (usize, Ts),
|
||||
bt: usize,
|
||||
marked: &'a MarkedContent,
|
||||
carry: &'a mut Carry,
|
||||
out: &'a mut Vec<Shown>,
|
||||
}
|
||||
|
||||
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
|
||||
let ts = &mut *state.ts;
|
||||
let tm = &mut *state.tm;
|
||||
let tlm = &mut *state.tlm;
|
||||
let at_bt = state.at_bt;
|
||||
let bt = state.bt;
|
||||
let marked = state.marked;
|
||||
let carry = &mut *state.carry;
|
||||
let out = &mut *state.out;
|
||||
if matches!(op.op.as_slice(), b"'" | b"\"") {
|
||||
if op.is("\"") {
|
||||
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
|
||||
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
|
||||
}
|
||||
*tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm);
|
||||
*tm = *tlm;
|
||||
}
|
||||
let Some((name, m)) = ts.font.clone() else { return Ok(()) };
|
||||
// Pieces: strings, and TJ number adjustments.
|
||||
let pieces: Vec<Object> = match op.op.as_slice() {
|
||||
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
|
||||
// takes the one immediately before it, as the renderer and every other
|
||||
// reader here do (#3569) -- not the first, which a reader counting operands
|
||||
// from the front of a surplus list would otherwise take.
|
||||
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
|
||||
_ => op.operands.last().cloned().into_iter().collect(),
|
||||
};
|
||||
// Tc is already included in the pen advance; TJ may encode tracking instead.
|
||||
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
|
||||
let trm0 = tm.then(&ts.ctm);
|
||||
let start = trm0.apply(0.0, ts.rise);
|
||||
let mut text = String::new();
|
||||
let mut decodable = true;
|
||||
let mut x_text = 0.0;
|
||||
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
|
||||
// on it horizontally; `half` is half the widest glyph.
|
||||
let vertical = ts.vertical;
|
||||
let (mut y_text, mut half) = (0.0, 0.0_f64);
|
||||
// Where each word's glyphs start and end along the run (text space), and whether
|
||||
// the next glyph continues the last word.
|
||||
let mut words: Vec<(f64, f64)> = Vec::new();
|
||||
let mut in_word = false;
|
||||
let mut glyph_count = 0_usize;
|
||||
for p in &pieces {
|
||||
match p {
|
||||
Object::String(s) => {
|
||||
for (code, len) in m.codes(&s.bytes) {
|
||||
glyph_count = glyph_count.saturating_add(1);
|
||||
let shown = m.text_of(code);
|
||||
let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
|
||||
match shown {
|
||||
Some(t) => {
|
||||
carry.text_left = carry
|
||||
.text_left
|
||||
.checked_sub(t.len())
|
||||
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
|
||||
text.push_str(&t);
|
||||
}
|
||||
None => decodable = false,
|
||||
}
|
||||
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
|
||||
if let Some(w1) = vertical {
|
||||
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
|
||||
if !blank {
|
||||
let ink_end = y_text + w1 * ts.size;
|
||||
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
|
||||
match words.last_mut() {
|
||||
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
|
||||
_ => words.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
in_word = !blank;
|
||||
y_text += w1 * ts.size + spacing;
|
||||
continue;
|
||||
}
|
||||
if !blank {
|
||||
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
|
||||
// A negative font size or `Tz` runs the glyphs backwards:
|
||||
// the word is the union of its glyphs, whichever way they go.
|
||||
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
|
||||
match words.last_mut() {
|
||||
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
|
||||
_ => words.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
in_word = !blank;
|
||||
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
|
||||
x_text += w * ts.scale;
|
||||
}
|
||||
}
|
||||
other => {
|
||||
if let Some(n) = other.as_f64() {
|
||||
if vertical.is_some() {
|
||||
// Down by n/1000 em, not scaled by `Tz`.
|
||||
let dy = -n / 1000.0 * ts.size;
|
||||
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
if dy.abs() > ts.size.abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
y_text += dy;
|
||||
continue;
|
||||
}
|
||||
let dx = -n / 1000.0 * ts.size * ts.scale;
|
||||
// A large gap inside TJ reads as a space.
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
// Tracking and kerning keep a word together; a larger jump
|
||||
// ends it (whatever the sign of the size or `Tz`).
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
x_text += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm);
|
||||
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
|
||||
// The box from `a` to `b` along the run: from descent to ascent across it, or for
|
||||
// vertical text, the widest glyph's width centred on the pen.
|
||||
let span = |a: f64, b: f64| match vertical {
|
||||
Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)],
|
||||
None => [
|
||||
trm0.apply(a, ts.rise + m.descent * ts.size),
|
||||
trm0.apply(b, ts.rise + m.descent * ts.size),
|
||||
trm0.apply(a, ts.rise + m.ascent * ts.size),
|
||||
trm0.apply(b, ts.rise + m.ascent * ts.size),
|
||||
],
|
||||
};
|
||||
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
|
||||
let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
|
||||
// The same corners along and across the baseline: the run's own box, not its
|
||||
// upright bounding box turned (which is wider by the box's height on a slant).
|
||||
let dir = direction(&trm0);
|
||||
let frame = corners
|
||||
.iter()
|
||||
.map(|&p| to_frame(dir, p))
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
|
||||
let ink = words
|
||||
.iter()
|
||||
.map(|&(a, b)| {
|
||||
span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
|
||||
})
|
||||
.collect();
|
||||
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
|
||||
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
|
||||
// It must not create a phantom editable line or enlarge its neighbours' boxes.
|
||||
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
|
||||
return Ok(());
|
||||
}
|
||||
out.push(Shown {
|
||||
op: i,
|
||||
extra_ops: Vec::new(),
|
||||
glyph_count,
|
||||
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
|
||||
// preview divides by size × `Tz`, so `Tz` is put back here.
|
||||
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
|
||||
tracking: 0.0,
|
||||
tm: Matrix([
|
||||
tm.0[0],
|
||||
tm.0[1],
|
||||
tm.0[2],
|
||||
tm.0[3],
|
||||
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
|
||||
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
|
||||
]),
|
||||
state: ts.clone(),
|
||||
tlm: *tlm,
|
||||
bt_op: at_bt.0,
|
||||
bt_state: at_bt.1.clone(),
|
||||
text,
|
||||
rect,
|
||||
frame,
|
||||
ink,
|
||||
baseline: start.1,
|
||||
start_x: start.0,
|
||||
end_x: end.0,
|
||||
end_y: end.1,
|
||||
bt,
|
||||
font: name,
|
||||
base_font: m.base_font.clone(),
|
||||
face: ts.face.clone(),
|
||||
size: if size_user > 0.0 { size_user } else { ts.size },
|
||||
bold: m.bold,
|
||||
italic: m.italic,
|
||||
visual_order: m.visual_order,
|
||||
decodable,
|
||||
scope: marked.regions.last().copied().flatten(),
|
||||
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
|
||||
logical_order: false,
|
||||
actual_text: false,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn interpret(
|
||||
doc: &Document,
|
||||
ops: &[Op],
|
||||
@@ -487,74 +764,25 @@ pub(super) fn interpret(
|
||||
let mut out: Vec<Shown> = Vec::new();
|
||||
let mut ts = carry.ts.clone();
|
||||
let mut at_bt = (0usize, ts.clone());
|
||||
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
|
||||
let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack);
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
let mut bt = 0usize;
|
||||
let mut marked = std::mem::take(&mut carry.marked);
|
||||
let mut layers = std::mem::take(&mut carry.layers);
|
||||
let mut actual: Vec<Option<(usize, usize, String)>> = Vec::new();
|
||||
let mut marked =
|
||||
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
|
||||
// sequence (#3573). Both stacks stay bounded by the number of operators.
|
||||
b"BMC" | b"BDC" => {
|
||||
// Marked text paragraphs/headings are authoritative boundaries. Ignore
|
||||
// artifacts and inner spans, which inherit their enclosing region.
|
||||
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
|
||||
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
|
||||
// A layer named in the resources is told apart by its name, an inline one by where it opens.
|
||||
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
|
||||
layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec)));
|
||||
// The property list is inline or named in the resources' /Properties (§14.6.2).
|
||||
let list = logical_text.and_then(|properties| match op.operands.get(1) {
|
||||
Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
|
||||
other => other.and_then(Object::as_dict).cloned(),
|
||||
});
|
||||
let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
|
||||
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
|
||||
_ => None,
|
||||
});
|
||||
actual.push(logical.map(|s| (out.len(), i, s)));
|
||||
marked.begin(doc, op, i, logical_text, out.len());
|
||||
}
|
||||
b"EMC" => {
|
||||
marked.pop();
|
||||
layers.pop();
|
||||
if let Some(Some((start, open, logical))) = actual.pop() {
|
||||
let tail = out.split_off(start.min(out.len()));
|
||||
let mut items = tail.into_iter();
|
||||
if let Some(mut first) = items.next() {
|
||||
for shown in items {
|
||||
first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count);
|
||||
first.extra_ops.push(shown.op);
|
||||
first.extra_ops.extend(shown.extra_ops);
|
||||
first.rect = [
|
||||
first.rect[0].min(shown.rect[0]),
|
||||
first.rect[1].min(shown.rect[1]),
|
||||
first.rect[2].max(shown.rect[2]),
|
||||
first.rect[3].max(shown.rect[3]),
|
||||
];
|
||||
first.frame = union(first.frame, shown.frame);
|
||||
first.ink.extend(shown.ink);
|
||||
}
|
||||
first.extra_ops.extend([open, i]);
|
||||
// Only right-to-left ActualText is already in typing order and must stay
|
||||
// out of the line's bidi recovery. Word and Chrome wrap Latin ligatures
|
||||
// (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line.
|
||||
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
|
||||
first.actual_text = true;
|
||||
first.text = logical;
|
||||
first.start_x = first.rect[0];
|
||||
first.end_x = first.rect[2];
|
||||
first.end_y = first.baseline;
|
||||
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
|
||||
out.push(first);
|
||||
}
|
||||
}
|
||||
marked.end(i, &mut out);
|
||||
}
|
||||
b"q" => stack.push(ts.clone()),
|
||||
b"q" => stack.push((ts.clone(), tm, tlm)),
|
||||
b"Q" => {
|
||||
if let Some(s) = stack.pop() {
|
||||
ts = s;
|
||||
if let Some(saved) = stack.pop() {
|
||||
(ts, tm, tlm) = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
@@ -650,207 +878,16 @@ pub(super) fn interpret(
|
||||
tm = tlm;
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" => {
|
||||
if matches!(op.op.as_slice(), b"'" | b"\"") {
|
||||
if op.is("\"") {
|
||||
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
|
||||
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
|
||||
}
|
||||
tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm);
|
||||
tm = tlm;
|
||||
}
|
||||
let Some((name, m)) = ts.font.clone() else { continue };
|
||||
// Pieces: strings, and TJ number adjustments.
|
||||
let pieces: Vec<Object> = match op.op.as_slice() {
|
||||
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
|
||||
// takes the one immediately before it, as the renderer and every other
|
||||
// reader here do (#3569) -- not the first, which a reader counting operands
|
||||
// from the front of a surplus list would otherwise take.
|
||||
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
|
||||
_ => op.operands.last().cloned().into_iter().collect(),
|
||||
};
|
||||
// Tc is already included in the pen advance; TJ may encode tracking instead.
|
||||
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
|
||||
let trm0 = tm.then(&ts.ctm);
|
||||
let start = trm0.apply(0.0, ts.rise);
|
||||
let mut text = String::new();
|
||||
let mut decodable = true;
|
||||
let mut x_text = 0.0;
|
||||
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
|
||||
// on it horizontally; `half` is half the widest glyph.
|
||||
let vertical = ts.vertical;
|
||||
let (mut y_text, mut half) = (0.0, 0.0_f64);
|
||||
// Where each word's glyphs start and end along the run (text space), and whether
|
||||
// the next glyph continues the last word.
|
||||
let mut words: Vec<(f64, f64)> = Vec::new();
|
||||
let mut in_word = false;
|
||||
let mut glyph_count = 0_usize;
|
||||
for p in &pieces {
|
||||
match p {
|
||||
Object::String(s) => {
|
||||
for (code, len) in m.codes(&s.bytes) {
|
||||
glyph_count = glyph_count.saturating_add(1);
|
||||
let shown = m.text_of(code);
|
||||
let blank =
|
||||
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
|
||||
match shown {
|
||||
Some(t) => {
|
||||
carry.text_left = carry
|
||||
.text_left
|
||||
.checked_sub(t.len())
|
||||
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
|
||||
text.push_str(&t);
|
||||
}
|
||||
None => decodable = false,
|
||||
}
|
||||
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
|
||||
if let Some(w1) = vertical {
|
||||
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
|
||||
if !blank {
|
||||
let ink_end = y_text + w1 * ts.size;
|
||||
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
|
||||
match words.last_mut() {
|
||||
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
|
||||
_ => words.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
in_word = !blank;
|
||||
y_text += w1 * ts.size + spacing;
|
||||
continue;
|
||||
}
|
||||
if !blank {
|
||||
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
|
||||
// A negative font size or `Tz` runs the glyphs backwards:
|
||||
// the word is the union of its glyphs, whichever way they go.
|
||||
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
|
||||
match words.last_mut() {
|
||||
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
|
||||
_ => words.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
in_word = !blank;
|
||||
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
|
||||
x_text += w * ts.scale;
|
||||
}
|
||||
}
|
||||
other => {
|
||||
if let Some(n) = other.as_f64() {
|
||||
if vertical.is_some() {
|
||||
// Down by n/1000 em, not scaled by `Tz`.
|
||||
let dy = -n / 1000.0 * ts.size;
|
||||
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
if dy.abs() > ts.size.abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
y_text += dy;
|
||||
continue;
|
||||
}
|
||||
let dx = -n / 1000.0 * ts.size * ts.scale;
|
||||
// A large gap inside TJ reads as a space.
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
// Tracking and kerning keep a word together; a larger jump
|
||||
// ends it (whatever the sign of the size or `Tz`).
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
x_text += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm);
|
||||
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
|
||||
// The box from `a` to `b` along the run: from descent to ascent across it, or for
|
||||
// vertical text, the widest glyph's width centred on the pen.
|
||||
let span = |a: f64, b: f64| match vertical {
|
||||
Some(_) => {
|
||||
[trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)]
|
||||
}
|
||||
None => [
|
||||
trm0.apply(a, ts.rise + m.descent * ts.size),
|
||||
trm0.apply(b, ts.rise + m.descent * ts.size),
|
||||
trm0.apply(a, ts.rise + m.ascent * ts.size),
|
||||
trm0.apply(b, ts.rise + m.ascent * ts.size),
|
||||
],
|
||||
};
|
||||
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
|
||||
let rect = corners
|
||||
.iter()
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
|
||||
// The same corners along and across the baseline: the run's own box, not its
|
||||
// upright bounding box turned (which is wider by the box's height on a slant).
|
||||
let dir = direction(&trm0);
|
||||
let frame = corners
|
||||
.iter()
|
||||
.map(|&p| to_frame(dir, p))
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
|
||||
let ink = words
|
||||
.iter()
|
||||
.map(|&(a, b)| {
|
||||
span(a, b)
|
||||
.iter()
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
|
||||
})
|
||||
.collect();
|
||||
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
|
||||
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
|
||||
// It must not create a phantom editable line or enlarge its neighbours' boxes.
|
||||
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
|
||||
continue;
|
||||
}
|
||||
out.push(Shown {
|
||||
op: i,
|
||||
extra_ops: Vec::new(),
|
||||
glyph_count,
|
||||
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
|
||||
// preview divides by size × `Tz`, so `Tz` is put back here.
|
||||
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
|
||||
tracking: 0.0,
|
||||
tm: Matrix([
|
||||
tm.0[0],
|
||||
tm.0[1],
|
||||
tm.0[2],
|
||||
tm.0[3],
|
||||
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
|
||||
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
|
||||
]),
|
||||
state: ts.clone(),
|
||||
tlm,
|
||||
bt_op: at_bt.0,
|
||||
bt_state: at_bt.1.clone(),
|
||||
text,
|
||||
rect,
|
||||
frame,
|
||||
ink,
|
||||
baseline: start.1,
|
||||
start_x: start.0,
|
||||
end_x: end.0,
|
||||
end_y: end.1,
|
||||
bt,
|
||||
font: name,
|
||||
base_font: m.base_font.clone(),
|
||||
face: ts.face.clone(),
|
||||
size: if size_user > 0.0 { size_user } else { ts.size },
|
||||
bold: m.bold,
|
||||
italic: m.italic,
|
||||
visual_order: m.visual_order,
|
||||
decodable,
|
||||
scope: marked.last().copied().flatten(),
|
||||
paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
|
||||
logical_order: false,
|
||||
actual_text: false,
|
||||
});
|
||||
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
|
||||
show_text(op, i, &mut state)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
carry.ts = ts;
|
||||
carry.stack = stack;
|
||||
carry.marked = marked;
|
||||
carry.layers = layers;
|
||||
carry.marked = marked.regions;
|
||||
carry.layers = marked.layers;
|
||||
positioned_tracking(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::fallback::{same_font, same_paint};
|
||||
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
|
||||
use super::*;
|
||||
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
|
||||
|
||||
impl TextLine {
|
||||
/// The baseline's height in user space.
|
||||
|
||||
+599
-337
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
//! Geometry-based regions and mixed-style rewriting of existing text.
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
|
||||
no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family,
|
||||
source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs,
|
||||
};
|
||||
|
||||
#[path = "text_regions/grouping.rs"]
|
||||
mod grouping;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
|
||||
|
||||
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
|
||||
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,10 @@
|
||||
//! another one's output; the commit writes into the document it was opened on, and refuses if
|
||||
//! that document has changed since.
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from,
|
||||
text_lines,
|
||||
};
|
||||
|
||||
/// One paragraph being edited: the document as it was opened, and the page's lines then.
|
||||
pub struct TextSession {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Times Export ▸ Image over every page of a PDF, for before and after comparisons.
|
||||
//!
|
||||
//! Usage: `export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]` (defaults: 150 dpi, PNG, 7 runs). Each
|
||||
//! run exports every page through [`Exporter::images`] from a fresh exporter, as Export ▸ Image
|
||||
//! does, and prints its wall time; the summary gives the median and the minimum, the total size
|
||||
//! and an FNV-1a hash of every file in page order. Equal hashes mean equal output. Build with
|
||||
//! `--release`; timings are diagnostic, never a CI deadline.
|
||||
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use pdfcraft_engine::Session;
|
||||
use pdfcraft_engine::export::{Exporter, ImageFormat};
|
||||
|
||||
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let path = args.next().ok_or("usage: export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]")?;
|
||||
let dpi: f64 = args.next().map_or(Ok(150.0), |s| s.parse())?;
|
||||
let format = match args.next().as_deref() {
|
||||
None | Some("png") => ImageFormat::Png,
|
||||
Some("jpeg" | "jpg") => ImageFormat::Jpeg { quality: 85 },
|
||||
Some("tiff" | "tif") => ImageFormat::Tiff,
|
||||
Some(other) => return Err(format!("unknown format {other:?}").into()),
|
||||
};
|
||||
let runs: usize = args.next().map_or(Ok(7), |s| s.parse())?;
|
||||
let mut session = Session::new();
|
||||
let id = session.open("bench.pdf", None, Arc::new(std::fs::read(&path)?), None)?;
|
||||
let src = session.get(id).ok_or("the document did not open")?.export_source();
|
||||
let pages: Vec<usize> = (0..src.pages).collect();
|
||||
let mut times = Vec::new();
|
||||
let mut summary = None;
|
||||
for run in 0..runs {
|
||||
let start = Instant::now();
|
||||
let mut exporter = Exporter::from_source(src.clone());
|
||||
let (mut hash, mut bytes) = (FNV_OFFSET, 0usize);
|
||||
exporter.images(&pages, dpi, format, |_, file| {
|
||||
bytes += file.len();
|
||||
for b in file {
|
||||
hash = (hash ^ u64::from(b)).wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("run {}: {ms:.1} ms", run + 1);
|
||||
times.push(ms);
|
||||
summary = Some((bytes, hash));
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
if let (Some(median), Some(min), Some((bytes, hash))) = (times.get(times.len() / 2), times.first(), summary) {
|
||||
println!(
|
||||
"{} pages, {} at {dpi} dpi: median {median:.1} ms, min {min:.1} ms ({runs} runs), {bytes} bytes, hash {hash:016x}",
|
||||
pages.len(),
|
||||
format.label()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -378,6 +378,8 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
|
||||
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
|
||||
c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"),
|
||||
// Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693).
|
||||
c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"),
|
||||
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
|
||||
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
|
||||
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
|
||||
@@ -392,6 +394,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
|
||||
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
|
||||
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
|
||||
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
|
||||
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
|
||||
c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"),
|
||||
c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize};
|
||||
|
||||
/// A change to an existing page image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -237,6 +237,16 @@ impl Document {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The styles the document's text is set in, the largest first, for bookmarks from text
|
||||
/// styles (#1693).
|
||||
/// Empty when the document doesn't allow copying: the samples are its text.
|
||||
pub fn text_styles(&self) -> Vec<crate::text_style_bookmarks::TextStyleUse> {
|
||||
if !self.allows_copying() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.editor.as_ref().map(|e| crate::text_style_bookmarks::styles(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based).
|
||||
pub fn text_lines(&self, page: usize) -> Vec<pdfcraft_edit::TextLine> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
|
||||
@@ -248,6 +258,11 @@ impl Document {
|
||||
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Page properties ▸ Tab order: each page's tab order, in page order.
|
||||
pub fn tab_orders(&self) -> Vec<TabOrder> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add alternate text: the figures, in document order.
|
||||
pub fn figures(&self) -> Vec<a11y::Figure> {
|
||||
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
|
||||
|
||||
@@ -122,6 +122,11 @@ pub enum Edit {
|
||||
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
|
||||
/// "Untitled" bookmark (New Bookmarks from Structure).
|
||||
BookmarksFromStructure,
|
||||
/// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests
|
||||
/// under it, …), under a new first "Untitled" bookmark (#1693; see [`crate::text_style_bookmarks`]).
|
||||
BookmarksFromTextStyles {
|
||||
levels: Vec<crate::text_style_bookmarks::TextStyle>,
|
||||
},
|
||||
/// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels.
|
||||
NumberPages {
|
||||
from: usize,
|
||||
@@ -287,6 +292,12 @@ pub enum Edit {
|
||||
DeleteField {
|
||||
name: String,
|
||||
},
|
||||
/// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button
|
||||
/// of a group, or one copy of a duplicated field. The last widget takes the field with it.
|
||||
DeleteWidget {
|
||||
name: String,
|
||||
widget: usize,
|
||||
},
|
||||
/// Page properties ▸ Tab order, for pages (0-based).
|
||||
SetTabOrder {
|
||||
pages: Vec<usize>,
|
||||
@@ -517,7 +528,8 @@ pub enum Edit {
|
||||
links: Vec<(usize, Vec<[f64; 4]>, String)>,
|
||||
style: LinkStyle,
|
||||
},
|
||||
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text).
|
||||
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the
|
||||
/// comments of another PDF onto the pages with the same numbers.
|
||||
ImportData {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table};
|
||||
|
||||
/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not
|
||||
/// here: its wording is only the hole, because a batch carries its name as text (see
|
||||
@@ -39,6 +39,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Colour",
|
||||
"Style",
|
||||
"New bookmarks from structure",
|
||||
"New bookmarks from text styles",
|
||||
"Number pages",
|
||||
"Measure distance",
|
||||
"Measure perimeter",
|
||||
@@ -186,6 +187,7 @@ impl Edit {
|
||||
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
|
||||
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
|
||||
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
|
||||
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
|
||||
Edit::NumberPages { .. } => Label::of("Number pages"),
|
||||
// What was measured is part of the wording too, for the same reason.
|
||||
Edit::AddMeasurement(m) => Label::of(match m.kind {
|
||||
@@ -224,7 +226,7 @@ impl Edit {
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
Edit::AddField { .. } => Label::of("Add field"),
|
||||
Edit::SetFieldProps { .. } => Label::of("Change field properties"),
|
||||
Edit::DeleteField { .. } => Label::of("Delete field"),
|
||||
Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"),
|
||||
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
|
||||
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
|
||||
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
|
||||
};
|
||||
use crate::SourceProblem;
|
||||
use crate::sources::{comment_source, import_pdf_comments};
|
||||
|
||||
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
|
||||
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
|
||||
@@ -29,6 +34,17 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
Err(EditError::NotPermitted("page changes"))
|
||||
}
|
||||
}
|
||||
// Creates bookmarks (assembly), and their titles are the document's text, copied out of it
|
||||
// (Table 22, bit 5), as an export of it would be.
|
||||
Edit::BookmarksFromTextStyles { .. } => {
|
||||
if !p.assemble() {
|
||||
Err(EditError::NotPermitted("page changes"))
|
||||
} else if !p.copy() {
|
||||
Err(EditError::NotPermitted("copying text and images"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::AddCustomStamp { .. }
|
||||
@@ -103,6 +119,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::XfaToStatic
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::DeleteField { .. }
|
||||
| Edit::DeleteWidget { .. }
|
||||
| Edit::DuplicateField { .. }
|
||||
| Edit::SetTabOrder { .. }
|
||||
| Edit::MoveInTabOrder { .. }
|
||||
@@ -277,10 +294,30 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::BookmarksFromStructure => {
|
||||
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
|
||||
.into_iter()
|
||||
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj })
|
||||
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None })
|
||||
.collect();
|
||||
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
|
||||
}
|
||||
Edit::BookmarksFromTextStyles { levels } => {
|
||||
if levels.is_empty() {
|
||||
return Err(EditError::Invalid("choose a text style for at least one bookmark level".into()));
|
||||
}
|
||||
// Each line goes to the first level whose style it is in: a style given again, or one
|
||||
// within the size tolerance of an earlier level's, would leave its level empty.
|
||||
for (i, a) in levels.iter().enumerate() {
|
||||
if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) {
|
||||
let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2));
|
||||
return Err(EditError::Invalid(format!(
|
||||
"bookmark levels {first} and {second} have the same text style: choose a different one for each level"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let entries = crate::text_style_bookmarks::headings(doc, levels);
|
||||
if entries.is_empty() {
|
||||
return Err(EditError::Invalid("no text is set in the chosen styles".into()));
|
||||
}
|
||||
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
|
||||
}
|
||||
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
|
||||
Edit::AddMeasurement(m) => {
|
||||
measure::add(doc, m, &cx.meta())?;
|
||||
@@ -328,7 +365,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
|
||||
// the strikeout's `/NM` (#2666).
|
||||
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
|
||||
pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?;
|
||||
pdfcraft_annot::add_text_replacement(
|
||||
doc,
|
||||
pdfcraft_annot::TextReplacement {
|
||||
page: *page,
|
||||
quads,
|
||||
replacement: text,
|
||||
author,
|
||||
strike,
|
||||
caret,
|
||||
strike_meta: &strike_meta,
|
||||
caret_meta: &caret_meta,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Edit::MarkAnnotation { page, index, marked, author } => {
|
||||
pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?;
|
||||
@@ -338,7 +387,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
|
||||
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
|
||||
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
|
||||
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
|
||||
pdfcraft_annot::set_style(
|
||||
doc,
|
||||
*page,
|
||||
*index,
|
||||
pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() },
|
||||
&cx.meta(),
|
||||
)?;
|
||||
}
|
||||
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
|
||||
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
|
||||
@@ -425,6 +480,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
}
|
||||
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
|
||||
Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?,
|
||||
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
|
||||
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
|
||||
// As after a script edit: the values the new order gives show at once.
|
||||
@@ -612,7 +668,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into())));
|
||||
}
|
||||
}
|
||||
Edit::ImportData { bytes, .. } => {
|
||||
Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => {
|
||||
let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?;
|
||||
import_pdf_comments(doc, &src)?;
|
||||
}
|
||||
Edit::ImportData { name, bytes } => {
|
||||
// An XFA form takes XFA data files (XDP, datasets, or a data document) as its data;
|
||||
// XFDF, FDF, Acrobat's XML and tables go the usual way.
|
||||
let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten();
|
||||
@@ -626,9 +686,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes));
|
||||
xfa_after_data_change(doc, cx, true)?;
|
||||
}
|
||||
Some(Err(why)) => return Err(EditError::Invalid(why)),
|
||||
Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)),
|
||||
_ => {
|
||||
pdfcraft_xfdf::import(doc, bytes)?;
|
||||
// A PDF with bytes before its header, within the 1,024 the reader allows, is read
|
||||
// as a PDF when it is not a data file. A file the PDF reader cannot open keeps
|
||||
// the data importer's error.
|
||||
let mut data = doc.clone();
|
||||
match pdfcraft_xfdf::import(&mut data, bytes) {
|
||||
Ok(_) => *doc = data,
|
||||
Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) {
|
||||
Ok(src) => import_pdf_comments(doc, &src)?,
|
||||
Err(SourceProblem::Unreadable(_)) => return Err(e.into()),
|
||||
Err(p) => return Err(EditError::Source(format!("{name}: {p}"))),
|
||||
},
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+185
-5
@@ -4,13 +4,52 @@
|
||||
//! text, from a
|
||||
//! document's working file (so unsaved edits and hidden layers are respected, as on screen).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Condvar, Mutex, PoisonError, mpsc};
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// At most this many pages are drawn at once by [`Exporter::images`].
|
||||
const MAX_IMAGE_WORKERS: usize = 8;
|
||||
/// Bytes of rasters [`Exporter::images`] may hold at once: 1 GiB, about 16 Letter pages at 300 dpi.
|
||||
/// Each worker holds one page (the raster, its RGB copy and the encoded file), so large pages run
|
||||
/// on fewer workers; a page over the cap still exports, on one worker, as it did before.
|
||||
const IMAGE_MEMORY: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// Page `page` drawn at `dpi` and encoded as `format`, saying it was drawn at `used` dpi.
|
||||
fn draw_image(renderer: &mut PageRenderer, page: usize, dpi: f64, format: ImageFormat, used: f64) -> Result<Vec<u8>, String> {
|
||||
let r = renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
}
|
||||
encode_image(r.width, r.height, &r.rgba, format, used)
|
||||
}
|
||||
|
||||
/// Tells the workers of [`Exporter::images`] to stop when dropped.
|
||||
struct StopWorkers<'a> {
|
||||
stop: &'a AtomicBool,
|
||||
handed_over: &'a (Mutex<usize>, Condvar),
|
||||
}
|
||||
|
||||
impl Drop for StopWorkers<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
// Taken under the lock, so a worker between its check and its wait still sees it.
|
||||
drop(self.handed_over.0.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
self.handed_over.1.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders and extracts pages of one document state.
|
||||
pub struct Exporter {
|
||||
renderer: PageRenderer,
|
||||
/// What the renderer draws, for the extra renderers [`Exporter::images`] starts.
|
||||
bytes: std::sync::Arc<Vec<u8>>,
|
||||
config: pdfcraft_render::RenderConfig,
|
||||
pages: usize,
|
||||
/// Each page's displayed size in points, for [`Exporter::dpi_used`].
|
||||
sizes: Vec<(f32, f32)>,
|
||||
@@ -65,7 +104,13 @@ impl Exporter {
|
||||
/// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what.
|
||||
/// A source whose `config.reject_oversize` is set refuses such pages instead.
|
||||
pub fn from_source(src: ExportSource) -> Self {
|
||||
Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages, sizes: src.sizes }
|
||||
Self {
|
||||
renderer: PageRenderer::new(src.bytes.clone(), src.config.clone()),
|
||||
bytes: src.bytes,
|
||||
config: src.config,
|
||||
pages: src.pages,
|
||||
sizes: src.sizes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked
|
||||
@@ -96,11 +141,146 @@ impl Exporter {
|
||||
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
|
||||
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
self.check(page)?;
|
||||
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
let used = self.dpi_used(page, dpi);
|
||||
draw_image(&mut self.renderer, page, dpi, format, used)
|
||||
}
|
||||
|
||||
/// Pages `pages` as image files of `format`, handed to `sink` in order with their page
|
||||
/// number: the same files as [`Exporter::image`] for each page in turn. Encoding a page costs
|
||||
/// several times what drawing it does, so several pages are drawn and encoded at once on
|
||||
/// worker threads, each with its own renderer, while `sink` runs on the calling thread.
|
||||
/// Workers draw at most one page each ahead of `sink`, and fewer run when the pages are
|
||||
/// large, so memory stays bounded. Stops at the first page that fails or the first error `sink`
|
||||
/// returns, and returns it; every page before it has been handed to `sink`.
|
||||
pub fn images(
|
||||
&mut self,
|
||||
pages: &[usize],
|
||||
dpi: f64,
|
||||
format: ImageFormat,
|
||||
sink: impl FnMut(usize, Vec<u8>) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let workers = self.image_workers(pages, dpi);
|
||||
self.images_on(workers, pages, dpi, format, sink)
|
||||
}
|
||||
|
||||
/// [`Exporter::images`] with `workers` pages drawn at once (one: in turn, on this thread).
|
||||
pub(crate) fn images_on(
|
||||
&mut self,
|
||||
workers: usize,
|
||||
pages: &[usize],
|
||||
dpi: f64,
|
||||
format: ImageFormat,
|
||||
mut sink: impl FnMut(usize, Vec<u8>) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
if workers <= 1 {
|
||||
for &p in pages {
|
||||
let img = self.image(p, dpi, format)?;
|
||||
sink(p, img)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
|
||||
let used: Vec<f64> = pages.iter().map(|p| self.dpi_used(*p, dpi)).collect();
|
||||
let count = self.pages;
|
||||
let (bytes, config) = (&self.bytes, &self.config);
|
||||
let next = AtomicUsize::new(0);
|
||||
let stop = AtomicBool::new(false);
|
||||
// How many pages `sink` has taken; a worker waits while its page is `workers` past it.
|
||||
let handed_over = (Mutex::new(0usize), Condvar::new());
|
||||
let (tx, rx) = mpsc::channel::<(usize, Result<Vec<u8>, String>)>();
|
||||
let mut first = Some(&mut self.renderer);
|
||||
std::thread::scope(|s| {
|
||||
let handles: Vec<_> = (0..workers)
|
||||
.map(|_| {
|
||||
let tx = tx.clone();
|
||||
let (first, used, next, stop, handed_over) = (first.take(), &used, &next, &stop, &handed_over);
|
||||
s.spawn(move || {
|
||||
let mut own = None;
|
||||
let renderer = match first {
|
||||
Some(r) => r,
|
||||
None => own.insert(PageRenderer::new(bytes.clone(), config.clone())),
|
||||
};
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let (Some(&page), Some(&used)) = (pages.get(i), used.get(i)) else { break };
|
||||
let mut taken = handed_over.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while i >= taken.saturating_add(workers) && !stop.load(Ordering::Relaxed) {
|
||||
taken = handed_over.1.wait(taken).unwrap_or_else(PoisonError::into_inner);
|
||||
}
|
||||
drop(taken);
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let img = if page < count {
|
||||
catch_unwind(AssertUnwindSafe(|| draw_image(renderer, page, dpi, format, used)))
|
||||
.unwrap_or_else(|_| Err(format!("page {}: the export crashed", page + 1)))
|
||||
} else {
|
||||
Err(format!("page {} does not exist", page + 1))
|
||||
};
|
||||
if tx.send((i, img)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
drop(tx);
|
||||
// Stops the workers however this ends, a panicking `sink` included: waiting ones
|
||||
// would otherwise keep the scope from returning.
|
||||
let stopper = StopWorkers { stop: &stop, handed_over: &handed_over };
|
||||
let mut ready = BTreeMap::new();
|
||||
let mut result = Ok(());
|
||||
let mut taken = 0;
|
||||
while let Some(&page) = pages.get(taken) {
|
||||
let Some(img) = ready.remove(&taken) else {
|
||||
match rx.recv() {
|
||||
Ok((i, img)) => {
|
||||
ready.insert(i, img);
|
||||
}
|
||||
Err(_) => {
|
||||
result = Err(format!("page {}: the export stopped unexpectedly", page + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = img.and_then(|img| sink(page, img)) {
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
taken += 1;
|
||||
*handed_over.0.lock().unwrap_or_else(PoisonError::into_inner) = taken;
|
||||
handed_over.1.notify_all();
|
||||
}
|
||||
drop(stopper);
|
||||
// Joined here, so a worker that panicked outside a page is not re-raised by the scope.
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// How many pages [`Exporter::images`] draws at once: one per core but one, up to
|
||||
/// [`MAX_IMAGE_WORKERS`], as many as [`IMAGE_MEMORY`] holds of the largest page, and one
|
||||
/// when there is nothing to share or no threads (wasm).
|
||||
fn image_workers(&self, pages: &[usize], dpi: f64) -> usize {
|
||||
if cfg!(target_arch = "wasm32") || pages.len() < 2 {
|
||||
return 1;
|
||||
}
|
||||
// One core is left for the interface, which keeps drawing while an export runs.
|
||||
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).saturating_sub(1).min(MAX_IMAGE_WORKERS);
|
||||
// The raster (RGBA), the RGB copy the encoders take, and room for the encoded file.
|
||||
let largest = pages
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let (w, h) = *self.sizes.get(*p)?;
|
||||
let k = self.dpi_used(*p, dpi) / 72.0;
|
||||
Some((f64::from(w) * k).ceil() * (f64::from(h) * k).ceil() * 8.0)
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
// A float-to-integer cast saturates (and NaN is 0).
|
||||
let by_memory = if largest > 0.0 { (IMAGE_MEMORY / largest) as usize } else { cores };
|
||||
cores.min(by_memory).min(pages.len()).max(1)
|
||||
}
|
||||
|
||||
/// The reading-order text of a page.
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
//! off, scripts don't run and AF calls keep working natively.
|
||||
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
|
||||
pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
|
||||
pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
|
||||
/// What scripts produced for the viewer since it last looked.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct JsOutput {
|
||||
pub alerts: Vec<String>,
|
||||
/// The messages scripts showed, in order, with their titles, icons and buttons.
|
||||
pub alerts: Vec<Message>,
|
||||
pub console: Vec<String>,
|
||||
pub requests: Vec<Request>,
|
||||
/// Script errors (as Acrobat's console shows them).
|
||||
@@ -195,6 +196,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
impl Scripts for JsRunner {
|
||||
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
|
||||
/// asked for; a refusal carries its last message.
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
|
||||
let states: Vec<_> = fields
|
||||
.iter()
|
||||
@@ -218,7 +221,7 @@ impl Scripts for JsRunner {
|
||||
rc: o.rc,
|
||||
value: o.value.clone(),
|
||||
changes: changes(&states, &o.changed),
|
||||
message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
|
||||
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ mod stamp_preview;
|
||||
mod test_hooks;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
pub mod text_style_bookmarks;
|
||||
pub mod word_count;
|
||||
pub mod xfa;
|
||||
mod xfa_form;
|
||||
@@ -66,7 +67,7 @@ pub use links::{
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::StampPreview;
|
||||
pub use text_preview::TextEditPreview;
|
||||
pub use text_preview::{TextEditPreview, UnmarkedPages};
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
|
||||
|
||||
@@ -88,7 +89,7 @@ pub use pdfcraft_forms::{
|
||||
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
|
||||
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
|
||||
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
|
||||
calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
|
||||
+15
-13
@@ -25,6 +25,17 @@ pub struct PreparedDocument {
|
||||
document: Document,
|
||||
}
|
||||
|
||||
struct DocumentOpenData<'a> {
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
info: DocInfo,
|
||||
structure: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
|
||||
render_password: Option<String>,
|
||||
password: Option<&'a str>,
|
||||
xfa: Option<XfaLayout>,
|
||||
}
|
||||
|
||||
impl OpenContext {
|
||||
/// Prepare a document without mutating the session that supplied this context.
|
||||
pub fn prepare_open(
|
||||
@@ -241,7 +252,8 @@ impl Session {
|
||||
};
|
||||
// XFA notes survive the document being re-read after edits.
|
||||
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
|
||||
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
|
||||
let mut document =
|
||||
Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa });
|
||||
// A script that ran away on open turns the form's scripts off for this document.
|
||||
document.xfa_template = if ran_away { None } else { xfa_template };
|
||||
document.js_output.append(script_output);
|
||||
@@ -254,18 +266,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// The last step of opening: build the document record and register it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_document(
|
||||
trust: &Arc<TrustStore>,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
mut info: DocInfo,
|
||||
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
|
||||
render_password: Option<String>,
|
||||
password: Option<&str>,
|
||||
xfa: Option<XfaLayout>,
|
||||
) -> Document {
|
||||
fn build_document(trust: &Arc<TrustStore>, data: DocumentOpenData<'_>) -> Document {
|
||||
let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data;
|
||||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let (mut editor, read_only_reason) = match cos {
|
||||
Ok(Ok(cos)) => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
use super::*;
|
||||
#[cfg(test)]
|
||||
use super::check_refresh_panic;
|
||||
use super::{
|
||||
Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session,
|
||||
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings,
|
||||
render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts,
|
||||
write_incremental, xfa, xfa_sync_datasets,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
|
||||
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state,
|
||||
source_kind, writable, write_full,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard,
|
||||
render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// The bytes to write for Save: an incremental update of the file as opened/last saved,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort,
|
||||
apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
pub fn new() -> Self {
|
||||
|
||||
@@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Optio
|
||||
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
||||
}
|
||||
|
||||
/// Import the comments of `src` onto the pages of `doc` with the same numbers.
|
||||
pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> {
|
||||
if pdfcraft_organize::import_comments(doc, src)? == 0 {
|
||||
return Err(pdfcraft_xfdf::DataError::NothingImported.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse another PDF to import comments from, whatever its permissions allow.
|
||||
pub(crate) fn comment_source(bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) {
|
||||
Ok(Ok(d)) => Ok(d),
|
||||
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
||||
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
|
||||
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
|
||||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
|
||||
|
||||
+379
-11
@@ -1849,6 +1849,153 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() {
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
|
||||
/// Three pages whose headings are set apart only by how they look (#1693): chapters in
|
||||
/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title
|
||||
/// runs over two lines, its bold font is another subset of the same font, and one section is set
|
||||
/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row.
|
||||
fn styled_headings_fixture() -> Vec<u8> {
|
||||
let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \
|
||||
BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET";
|
||||
let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \
|
||||
BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \
|
||||
BT /FB 14 Tf 72 600 Td (Section C) Tj ET";
|
||||
// Two sections on one row, the right one drawn first: read from the left.
|
||||
let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET";
|
||||
let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>";
|
||||
pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>"))
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>")
|
||||
.stream("", page1)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
.stream("", page2)
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>")
|
||||
.stream("", page3)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones:
|
||||
/// chapters at level 1, sections nested under the chapter before them. A two-line title is one
|
||||
/// bookmark; a subset of the same font and a size set by the text matrix count as the style.
|
||||
/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_nest_the_headings_and_undo() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
|
||||
let styles = s.get(id).unwrap().text_styles();
|
||||
let listed: Vec<(String, f64, bool, usize, usize)> =
|
||||
styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
[
|
||||
("Helvetica-Bold".to_string(), 18.0, true, 3, 2),
|
||||
("Helvetica-Bold".to_string(), 14.0, true, 5, 3),
|
||||
("Helvetica".to_string(), 11.0, false, 3, 2),
|
||||
]
|
||||
);
|
||||
assert_eq!(styles[0].sample, "Chapter One");
|
||||
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
|
||||
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap();
|
||||
let outline = s.get(id).unwrap().info.outline.clone();
|
||||
assert_eq!(
|
||||
outline_titles(&outline),
|
||||
["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"]
|
||||
);
|
||||
let first = &outline[0].children[0];
|
||||
match first.view {
|
||||
pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => {
|
||||
assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}")
|
||||
}
|
||||
ref v => panic!("{v:?}"),
|
||||
}
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles"));
|
||||
s.undo(id).unwrap();
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
// One level is enough; the sections are then left out.
|
||||
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap();
|
||||
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]);
|
||||
s.undo(id).unwrap();
|
||||
// Nothing chosen, or a style no text is in: refused, nothing changes.
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err();
|
||||
assert!(err.to_string().contains("at least one bookmark level"), "{err}");
|
||||
let unused = pdfcraft_engine_style(§ion, 30.0);
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err();
|
||||
assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}");
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style
|
||||
/// for two levels (or one within the size tolerance of the other) would leave the later level
|
||||
/// empty. Refused at the edit, for the dialog and automation alike; nothing changes.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
|
||||
let styles = s.get(id).unwrap().text_styles();
|
||||
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
|
||||
let near = pdfcraft_engine_style(§ion, section.size + 0.1);
|
||||
for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] {
|
||||
let n = levels.len();
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err();
|
||||
assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}");
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document
|
||||
/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_need_copying_permission() {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: -1 ^ 0b1_0000,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
|
||||
assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying());
|
||||
assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied");
|
||||
let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false };
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err();
|
||||
assert_eq!(err, EditError::NotPermitted("copying text and images"));
|
||||
let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
|
||||
assert!(!s.get(owner).unwrap().text_styles().is_empty());
|
||||
assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may");
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower,
|
||||
/// are two bookmarks, not one heading run on across the columns.
|
||||
#[test]
|
||||
fn headings_in_two_columns_are_two_bookmarks() {
|
||||
let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET";
|
||||
let bytes = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>")
|
||||
.stream("", page)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let style = s.get(id).unwrap().text_styles()[0].style.clone();
|
||||
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap();
|
||||
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]);
|
||||
}
|
||||
|
||||
/// `style` at another size.
|
||||
fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle {
|
||||
text_style_bookmarks::TextStyle { size, ..style.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_pages_shows_in_the_viewer_and_undoes() {
|
||||
let (mut s, id) = session_with(5);
|
||||
@@ -3002,6 +3149,45 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() {
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background"));
|
||||
}
|
||||
|
||||
/// Update previews its marks on the page without the ones it replaces: a yellow background
|
||||
/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one.
|
||||
/// The other kinds of mark and the page's own content stay, every page is prepared at once (the
|
||||
/// dialog steps through them), a document without that kind has nothing to prepare, and
|
||||
/// preparing changes nothing in the document.
|
||||
#[test]
|
||||
fn update_previews_on_the_page_without_the_marks_it_replaces() {
|
||||
let (mut s, id) = session_with(2);
|
||||
let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() };
|
||||
s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap();
|
||||
let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() };
|
||||
s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let (generation, dirty) = (doc.edit_generation(), doc.dirty);
|
||||
let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] {
|
||||
p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap()
|
||||
};
|
||||
let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count();
|
||||
let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count();
|
||||
assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background");
|
||||
|
||||
let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background");
|
||||
let page = unmarked.render_page(0, 1.0).unwrap();
|
||||
assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white");
|
||||
assert!(reds(&page) > 50, "the watermark stays");
|
||||
assert!(dark(&page) > 10, "the page text stays");
|
||||
|
||||
let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap();
|
||||
assert_eq!(reds(&page), 0, "without the watermark");
|
||||
assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays");
|
||||
|
||||
assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too");
|
||||
assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages");
|
||||
assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header");
|
||||
assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_indexes_match_after_open_and_save_when_requested() {
|
||||
let (mut source, source_id) = session_with(1);
|
||||
@@ -3068,7 +3254,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
|
||||
assert!(doc.links().is_empty());
|
||||
assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list");
|
||||
}
|
||||
metadata_and_comment_edits_keep_the_indexes(&mut session, id);
|
||||
link_edits_rebuild_only_the_link_list(&mut session, id);
|
||||
content_and_marker_edits_rebuild_the_markers(&mut session, id);
|
||||
}
|
||||
|
||||
fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) {
|
||||
session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap();
|
||||
assert!(session.get(id).unwrap().links().is_empty());
|
||||
assert!(session.get(id).unwrap().marks().is_empty());
|
||||
@@ -3077,7 +3268,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
|
||||
session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap();
|
||||
assert!(session.get(id).unwrap().links().is_empty());
|
||||
assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index");
|
||||
}
|
||||
|
||||
fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) {
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
@@ -3101,7 +3294,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
|
||||
session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap();
|
||||
assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes");
|
||||
assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes");
|
||||
}
|
||||
|
||||
fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) {
|
||||
session
|
||||
.apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } })
|
||||
.unwrap();
|
||||
@@ -3410,6 +3605,70 @@ fn pages_export_as_png_jpeg_and_tiff() {
|
||||
assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
|
||||
}
|
||||
|
||||
/// Export ▸ Image draws several pages at once: each file is the one exporting that page alone
|
||||
/// writes, handed over in the order asked for, whatever the number of pages drawn at once.
|
||||
#[test]
|
||||
fn pages_exported_at_once_are_the_files_each_page_exports_alone_in_order() {
|
||||
use crate::export::{Exporter, ImageFormat};
|
||||
let (s, id) = session_with(12);
|
||||
let pages = [3, 0, 11, 5, 5, 7, 1, 2, 10, 9, 8, 6, 4];
|
||||
for format in [ImageFormat::Png, ImageFormat::Jpeg { quality: 85 }, ImageFormat::Tiff] {
|
||||
let alone: Vec<(usize, Vec<u8>)> = pages.iter().map(|&p| (p, Exporter::new(s.get(id).unwrap()).image(p, 96.0, format).unwrap())).collect();
|
||||
for workers in [1, 2, 4, 8] {
|
||||
let mut got = Vec::new();
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
ex.images_on(workers, &pages, 96.0, format, |p, img| {
|
||||
got.push((p, img));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(got == alone, "{} with {workers} workers", format.label());
|
||||
}
|
||||
let mut got = Vec::new();
|
||||
Exporter::new(s.get(id).unwrap())
|
||||
.images(&pages, 96.0, format, |p, img| {
|
||||
got.push((p, img));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(got == alone, "{}", format.label());
|
||||
}
|
||||
}
|
||||
|
||||
/// A page that can't be exported, or a file that can't be written, stops the export there with
|
||||
/// its error: every page before it is handed over, none after it.
|
||||
#[test]
|
||||
fn exporting_pages_at_once_stops_at_the_first_error() {
|
||||
use crate::export::{Exporter, ImageFormat};
|
||||
let (s, id) = session_with(6);
|
||||
for workers in [1, 4] {
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
let mut got = Vec::new();
|
||||
let result = ex.images_on(workers, &[0, 1, 99, 2, 3], 72.0, ImageFormat::Png, |p, _| {
|
||||
got.push(p);
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(result, Err("page 100 does not exist".to_string()), "{workers} workers");
|
||||
assert_eq!(got, [0, 1], "{workers} workers");
|
||||
|
||||
let mut got = Vec::new();
|
||||
let result = ex.images_on(workers, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| {
|
||||
got.push(p);
|
||||
if p == 2 { Err("disk full".to_string()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(result, Err("disk full".to_string()), "{workers} workers");
|
||||
assert_eq!(got, [0, 1, 2], "{workers} workers");
|
||||
// The exporter is still usable afterwards.
|
||||
assert!(ex.image(5, 72.0, ImageFormat::Png).is_ok());
|
||||
}
|
||||
// A panicking sink stops the workers rather than leaving them waiting for it.
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
ex.images_on(4, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| if p == 1 { panic!("sink") } else { Ok(()) })
|
||||
}));
|
||||
assert!(panicked.is_err());
|
||||
}
|
||||
|
||||
/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
|
||||
fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
|
||||
use tiff::{decoder::ifd::Value, tags::Tag};
|
||||
@@ -4735,6 +4994,8 @@ fn scripted_form_with(helpers: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// AcroForm field scripts validate, calculate and format through edits, and a button script's
|
||||
/// changes, messages and requests come back.
|
||||
#[test]
|
||||
fn form_javascript_validates_calculates_and_formats() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
@@ -4753,7 +5014,7 @@ fn form_javascript_validates_calculates_and_formats() {
|
||||
// A button script changes fields in one undoable step, and asks for a page.
|
||||
let o =
|
||||
s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap();
|
||||
assert_eq!(o.alerts, ["Done"]);
|
||||
assert_eq!(o.alerts, [crate::js::Message::plain("Done")]);
|
||||
assert_eq!(o.requests, [js::Request::Print]);
|
||||
assert_eq!(value(&s, "total"), ["12"], "recalculated after the change");
|
||||
assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only());
|
||||
@@ -4817,17 +5078,17 @@ fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() {
|
||||
assert!(s.take_js_output(id).alerts.is_empty());
|
||||
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Console message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
|
||||
assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]);
|
||||
let out = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Hello 2"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Hello 2")]);
|
||||
assert_eq!(out.requests, [js::Request::Print]);
|
||||
// The same context rule applies to format/validate/calculate through JsRunner.
|
||||
s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap();
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
|
||||
assert_eq!(s.take_js_output(id).alerts, ["Field message"]);
|
||||
assert_eq!(s.take_js_output(id).alerts, [js::Message::plain("Field message")]);
|
||||
// Saved XFA keeps its container scripts for inspection, but does not execute them.
|
||||
let saved = s.get(id).unwrap().bytes.clone();
|
||||
let reopened = s.open("saved.pdf", None, saved, None).unwrap();
|
||||
@@ -4852,7 +5113,7 @@ fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts(
|
||||
s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap();
|
||||
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Console message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4869,7 +5130,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() {
|
||||
let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default());
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("6"));
|
||||
assert_eq!(out.alerts, ["PDF message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("PDF message")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6177,6 +6438,8 @@ fn repeated_xfa_print_buttons_run_their_click_script() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows,
|
||||
/// with undo and save/reopen.
|
||||
#[test]
|
||||
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
@@ -6201,7 +6464,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
// A failing validate script shows its message; the value stays, as in Acrobat.
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert_eq!(value(&s, "total"), vec!["2500".to_string()]);
|
||||
// A button adds a row: new fields, same page, one undo step.
|
||||
let before = names(&s).len();
|
||||
@@ -6216,7 +6479,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
|
||||
// Message boxes reach the viewer.
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
|
||||
// The check box shows the hidden subform; undo hides it again.
|
||||
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
|
||||
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
|
||||
@@ -6231,7 +6494,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
// FormCalc scripts run too.
|
||||
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.alerts, vec!["FormCalc".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("FormCalc")]);
|
||||
// Saved and reopened: the visible subform, the values and the rows are what they were.
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let id2 = s.open("again.pdf", None, saved, None).unwrap();
|
||||
@@ -6452,6 +6715,7 @@ fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() {
|
||||
assert_eq!(value(&s, id, "total"), vec!["15".to_string()]);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form scripted in FormCalc runs as the JavaScript one does.
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
|
||||
@@ -6470,7 +6734,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert_eq!(value(&s, "qty"), vec!["500".to_string()]);
|
||||
// Buttons: a row is added, summed and removed; the message box reaches the viewer.
|
||||
let before = names(&s).len();
|
||||
@@ -6481,7 +6745,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap();
|
||||
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
|
||||
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
|
||||
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
|
||||
s.run_javascript(id, "", Some("removeRow")).unwrap();
|
||||
@@ -6616,6 +6880,8 @@ fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Hostile scripts run on open are merged, capped, kept from printing, saving and opening links,
|
||||
/// and noted.
|
||||
#[test]
|
||||
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
|
||||
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
|
||||
@@ -6647,6 +6913,36 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
|
||||
assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console);
|
||||
}
|
||||
|
||||
/// An XFA button's message boxes reach the viewer with their titles, icons and buttons, from
|
||||
/// JavaScript (`xfa.host.messageBox`, `app.alert` in its own order) and FormCalc alike, every
|
||||
/// one of them and in order.
|
||||
#[test]
|
||||
fn xfa_message_boxes_keep_their_title_icon_and_buttons() {
|
||||
use crate::js::{Message, MessageButtons, MessageIcon};
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template()
|
||||
.replace(
|
||||
r#"xfa.host.messageBox("Hello " + qty.rawValue);"#,
|
||||
r#"xfa.host.messageBox("Clear the rows?", "Order form", 2, 2); app.alert("Saved", 3, 0, "Order form");"#,
|
||||
)
|
||||
.replace(r#"$host.messageBox("FormCalc")"#, r#"$host.messageBox("Too many", "Limits", 1, 1)"#);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("boxes.pdf", None, bytes, None).expect("opens");
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
let titled = |text: &str, title: &str, icon, buttons| Message { text: text.into(), title: Some(title.into()), icon, buttons };
|
||||
assert_eq!(
|
||||
o.alerts,
|
||||
vec![
|
||||
titled("Clear the rows?", "Order form", MessageIcon::Question, MessageButtons::YesNo),
|
||||
titled("Saved", "Order form", MessageIcon::Status, MessageButtons::Ok),
|
||||
]
|
||||
);
|
||||
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.alerts, vec![titled("Too many", "Limits", MessageIcon::Warning, MessageButtons::OkCancel)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
|
||||
// Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per
|
||||
@@ -8042,7 +8338,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
|
||||
// Redo now succeeds cleanly.
|
||||
assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page");
|
||||
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
|
||||
rolls_back_metadata_scope(&mut s, id);
|
||||
rolls_back_protection_keys(&mut s, id);
|
||||
}
|
||||
|
||||
/// Undo/redo of a metadata edit rolled back after a refresh panic.
|
||||
fn rolls_back_metadata_scope(s: &mut Session, id: DocId) {
|
||||
// 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`.
|
||||
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap();
|
||||
let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false };
|
||||
@@ -8060,7 +8361,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
|
||||
assert_eq!(s.get(id).unwrap().info.title, None);
|
||||
assert_eq!(s.redo(id).unwrap().as_str(), "Change Title");
|
||||
assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft"));
|
||||
}
|
||||
|
||||
/// Undo/redo of a protection edit rolled back after a refresh panic.
|
||||
fn rolls_back_protection_keys(s: &mut Session, id: DocId) {
|
||||
// 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting.
|
||||
let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() };
|
||||
s.apply(id, Edit::Protect(protect)).unwrap();
|
||||
@@ -8632,6 +8936,70 @@ fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() {
|
||||
assert!(blue.count() > 20, "the pilcrow is drawn beside the caret");
|
||||
}
|
||||
|
||||
/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses
|
||||
/// a PDF that has none.
|
||||
#[test]
|
||||
fn importing_a_pdf_copies_its_comments() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap();
|
||||
let review = pdf_with_annots(&[
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>",
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>",
|
||||
]);
|
||||
s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap();
|
||||
let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link");
|
||||
assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf");
|
||||
assert!(s.get(id).unwrap().info.annotations.is_empty());
|
||||
|
||||
// Bytes before the header, as the reader allows them.
|
||||
let prefixed = [b"\r\n \n".as_slice(), &review].concat();
|
||||
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
|
||||
assert_eq!(contents(&s), [Some("from the review".to_string())]);
|
||||
s.undo(id).unwrap();
|
||||
|
||||
let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]);
|
||||
let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err();
|
||||
assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string());
|
||||
// A PDF that needs a password is refused with that reason, with or without bytes before its header.
|
||||
let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap();
|
||||
locked
|
||||
.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: 0,
|
||||
encrypt_metadata: true,
|
||||
seed: [1; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap();
|
||||
for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] {
|
||||
let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err();
|
||||
assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string());
|
||||
}
|
||||
|
||||
// An XFDF file that mentions the PDF header is still read as XFDF.
|
||||
let xfdf = r#"<?xml version="1.0"?><!-- exported from a file starting %PDF-1.7 --><xfdf xmlns="http://ns.adobe.com/xfdf/"><annots><text page="0" name="n1" rect="10,10,30,30"><contents>from the data file</contents></text></annots></xfdf>"#;
|
||||
s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap();
|
||||
assert_eq!(contents(&s), [Some("from the data file".to_string())]);
|
||||
}
|
||||
|
||||
/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of
|
||||
/// refusing it as a data file in an unknown encoding.
|
||||
#[test]
|
||||
fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() {
|
||||
let data = "<form><page1><country>JP</country></page1></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
|
||||
let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]);
|
||||
let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat();
|
||||
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
|
||||
let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect();
|
||||
assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}");
|
||||
}
|
||||
|
||||
/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …).
|
||||
fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone.
|
||||
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and,
|
||||
//! the same way, the page beneath a dragged added item or the marks an Update replaces.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -18,18 +19,75 @@ impl TextEditPreview {
|
||||
}
|
||||
|
||||
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
|
||||
render_one(&self.bytes, &self.config, self.page, scale)
|
||||
}
|
||||
}
|
||||
|
||||
/// A document's pages without its marks of one kind ([`Document::without_marks`]).
|
||||
pub struct UnmarkedPages {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
config: RenderConfig,
|
||||
/// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried
|
||||
/// again), and kept: parsing costs about as much as reading the document.
|
||||
parsed: Option<PageRenderer>,
|
||||
/// How many times it was parsed.
|
||||
#[cfg(test)]
|
||||
builds: u64,
|
||||
}
|
||||
|
||||
impl UnmarkedPages {
|
||||
pub fn renderer(&self) -> RenderPool {
|
||||
RenderPool::new(self.bytes.clone(), 1, self.config.clone())
|
||||
}
|
||||
|
||||
/// `page` at `scale`, on this thread.
|
||||
pub fn render_page(&mut self, page: usize, scale: f32) -> Result<RenderedPage, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("preview scale must be finite and positive".into());
|
||||
}
|
||||
guard(|| {
|
||||
let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone());
|
||||
let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() });
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
if self.parsed.is_none() {
|
||||
self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone()));
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.builds += 1;
|
||||
}
|
||||
Ok(out)
|
||||
})?
|
||||
}
|
||||
let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) };
|
||||
let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() }));
|
||||
let out = match out {
|
||||
Ok(out) => out,
|
||||
Err(e) => {
|
||||
// A panic may have left it half-way: parse afresh next time.
|
||||
self.parsed = None;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How many times [`Self::render_page`] has parsed the document.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parser_builds(&self) -> u64 {
|
||||
self.builds
|
||||
}
|
||||
}
|
||||
|
||||
/// `page` of `bytes` at `scale`, on this thread.
|
||||
fn render_one(bytes: &Arc<Vec<u8>>, config: &RenderConfig, page: usize, scale: f32) -> Result<RenderedPage, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("preview scale must be finite and positive".into());
|
||||
}
|
||||
guard(|| {
|
||||
let mut renderer = PageRenderer::new(bytes.clone(), config.clone());
|
||||
let out = renderer.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(out)
|
||||
})?
|
||||
}
|
||||
|
||||
impl Document {
|
||||
@@ -90,6 +148,33 @@ impl Document {
|
||||
})?
|
||||
}
|
||||
|
||||
/// The document without its marks of `kind`, on every page: what Update replaces them on, so
|
||||
/// its preview doesn't draw the new marks over the old ones (or tint the old background).
|
||||
/// Made once for all the pages a dialog steps through, as cloning and writing the document
|
||||
/// costs about a save. `None` when the document has none, and the thumbnails already are
|
||||
/// those pages. Read-only, like [`Self::text_edit_preview`].
|
||||
pub fn without_marks(&self, kind: crate::MarkKind) -> Result<Option<UnmarkedPages>, String> {
|
||||
if !self.marks().contains(&kind) {
|
||||
return Ok(None);
|
||||
}
|
||||
guard(|| {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let mut cos = editor.cos.clone();
|
||||
let n = pdfcraft_model::pages(&cos).len();
|
||||
if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::<Vec<_>>(), kind).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
|
||||
Ok(Some(UnmarkedPages {
|
||||
bytes,
|
||||
config: self.config.clone(),
|
||||
parsed: None,
|
||||
#[cfg(test)]
|
||||
builds: 0,
|
||||
}))
|
||||
})?
|
||||
}
|
||||
|
||||
/// An added item alone, on a transparent page: what follows the pointer while it is dragged,
|
||||
/// so the page under its first place doesn't come along. Read-only, like
|
||||
/// [`Self::added_content_preview`].
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how
|
||||
//! they look (chapter titles in one font at one size, sections in another), not tagged as
|
||||
//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the
|
||||
//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level.
|
||||
//!
|
||||
//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are
|
||||
//! subsets of one font), the size the text is shown at, and whether it is bold and italic.
|
||||
//! Lines are taken in reading order down each page: top to bottom, then left to right.
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
use pdfcraft_organize::OutlineEntry;
|
||||
|
||||
/// How a line of text looks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
/// The font's name, without a subset tag.
|
||||
pub font: String,
|
||||
/// The size the text is shown at, in points (rounded to a tenth).
|
||||
pub size: f64,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// Whether `other` is this style. Sizes within a quarter point match, as the same heading
|
||||
/// size is often written with rounding differences (12 and 11.96).
|
||||
pub(crate) fn matches(&self, other: &TextStyle) -> bool {
|
||||
self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart two sizes may be and still be one style.
|
||||
const SIZE_TOLERANCE: f64 = 0.25;
|
||||
|
||||
/// A style and how much text is set in it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyleUse {
|
||||
pub style: TextStyle,
|
||||
/// Lines in this style.
|
||||
pub lines: usize,
|
||||
/// Pages they are on.
|
||||
pub pages: usize,
|
||||
/// The first line in it, cut to [`SAMPLE_CHARS`] characters.
|
||||
pub sample: String,
|
||||
}
|
||||
|
||||
/// The most pages read: a longer document is listed and searched by its first pages.
|
||||
pub const MAX_PAGES: usize = 5000;
|
||||
/// The most bookmarks made at once.
|
||||
pub const MAX_BOOKMARKS: usize = 10_000;
|
||||
/// The longest bookmark title kept, in characters (a heading run on into a paragraph).
|
||||
const MAX_TITLE_CHARS: usize = 300;
|
||||
/// The longest sample shown for a style.
|
||||
const SAMPLE_CHARS: usize = 60;
|
||||
/// The most styles listed, the largest first.
|
||||
const MAX_STYLES: usize = 200;
|
||||
|
||||
/// One line of text in reading order.
|
||||
struct Line {
|
||||
page: usize,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
/// Top, bottom, left and right of its box in user space.
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
left: f64,
|
||||
right: f64,
|
||||
}
|
||||
|
||||
/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2).
|
||||
fn without_subset_tag(name: &str) -> &str {
|
||||
match name.split_once('+') {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that
|
||||
/// is only whitespace, or whose size can't be read, is left out.
|
||||
fn lines(doc: &Document) -> Vec<Line> {
|
||||
let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES);
|
||||
let mut out = Vec::new();
|
||||
for page in 0..pages {
|
||||
let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue };
|
||||
let mut on_page: Vec<Line> = found
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let text = l.text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// `size` is the size the line is shown at in user space (the `Tf` size times the
|
||||
// text rendering matrix), so `1 Tf` with a 14× text matrix is 14.
|
||||
let size = l.size;
|
||||
let [x0, y0, x1, y1] = l.rect;
|
||||
let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite());
|
||||
ok.then(|| Line {
|
||||
page,
|
||||
text,
|
||||
style: TextStyle {
|
||||
font: without_subset_tag(&l.base_font).to_string(),
|
||||
size: (size * 10.0).round() / 10.0,
|
||||
bold: l.bold,
|
||||
italic: l.italic,
|
||||
},
|
||||
top: y0.max(y1),
|
||||
bottom: y0.min(y1),
|
||||
left: x0.min(x1),
|
||||
right: x0.max(x1),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Down the page; along each row from the left. Boxes of one row differ by a fraction
|
||||
// of a point, so rows are compared at a tenth of a point.
|
||||
on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left)));
|
||||
out.extend(on_page);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The styles `doc`'s text is set in, the largest size first (then the most used): headings
|
||||
/// are usually larger than the text around them.
|
||||
pub fn styles(doc: &Document) -> Vec<TextStyleUse> {
|
||||
let mut uses: Vec<(TextStyleUse, Option<usize>)> = Vec::new();
|
||||
// The styles by font, weight, slant and size in quarter points, so a line is matched against
|
||||
// the few styles near its size rather than every style found (a page can hold thousands).
|
||||
// A size matches within a quarter point: the bucket's neighbours are looked at too.
|
||||
let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec<usize>> = std::collections::HashMap::new();
|
||||
let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64;
|
||||
for line in lines(doc) {
|
||||
let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b);
|
||||
let b = bucket(line.style.size);
|
||||
let found = [b.saturating_sub(1), b, b.saturating_add(1)]
|
||||
.into_iter()
|
||||
.filter_map(|b| index.get(&key(b)))
|
||||
.flatten()
|
||||
.copied()
|
||||
.find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style)));
|
||||
match found.and_then(|i| uses.get_mut(i)) {
|
||||
Some((u, last_page)) => {
|
||||
u.lines = u.lines.saturating_add(1);
|
||||
if *last_page != Some(line.page) {
|
||||
u.pages = u.pages.saturating_add(1);
|
||||
*last_page = Some(line.page);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let sample = line.text.chars().take(SAMPLE_CHARS).collect();
|
||||
index.entry(key(b)).or_default().push(uses.len());
|
||||
uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut uses: Vec<TextStyleUse> = uses.into_iter().map(|(u, _)| u).collect();
|
||||
uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines)));
|
||||
uses.truncate(MAX_STYLES);
|
||||
uses
|
||||
}
|
||||
|
||||
/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1
|
||||
/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A
|
||||
/// heading set on several lines (one after another in the same style, close below each other)
|
||||
/// is one bookmark. Each goes to the top of its heading.
|
||||
pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec<OutlineEntry> {
|
||||
let mut entries: Vec<OutlineEntry> = Vec::new();
|
||||
// The heading the previous line belonged to: its level, and the top, bottom, left and right
|
||||
// of its last line.
|
||||
let mut open: Option<(usize, f64, f64, f64, f64)> = None;
|
||||
for line in lines(doc) {
|
||||
let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else {
|
||||
open = None;
|
||||
continue;
|
||||
};
|
||||
// A heading goes on to the next row down, close below its last line and under it. A line
|
||||
// on the same row (two headings side by side), or in another column, is a heading of
|
||||
// its own.
|
||||
let continues = open.is_some_and(|(l, top, bottom, left, right)| {
|
||||
let size = line.style.size;
|
||||
let under = line.left < right && left < line.right;
|
||||
l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under
|
||||
}) && entries.last().is_some_and(|e| e.page == line.page);
|
||||
if continues {
|
||||
if let Some(e) = entries.last_mut()
|
||||
&& e.title.chars().count() < MAX_TITLE_CHARS
|
||||
{
|
||||
e.title.push(' ');
|
||||
e.title.push_str(&line.text);
|
||||
e.title = e.title.chars().take(MAX_TITLE_CHARS).collect();
|
||||
}
|
||||
} else {
|
||||
if entries.len() >= MAX_BOOKMARKS {
|
||||
break;
|
||||
}
|
||||
// The level counts from 1; the position in `levels` is at most its length, which a
|
||||
// caller keeps small (a few levels), so it fits a u8.
|
||||
let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX);
|
||||
entries.push(OutlineEntry {
|
||||
level,
|
||||
title: line.text.chars().take(MAX_TITLE_CHARS).collect(),
|
||||
page: line.page,
|
||||
element: None,
|
||||
top: Some(line.top),
|
||||
});
|
||||
}
|
||||
open = Some((level, line.top, line.bottom, line.left, line.right));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subset_tags_are_left_out_of_the_font_name() {
|
||||
assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold");
|
||||
assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold");
|
||||
assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters");
|
||||
assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters");
|
||||
assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag");
|
||||
assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_match_within_a_quarter_point() {
|
||||
let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false };
|
||||
assert!(s(12.0).matches(&s(11.8)));
|
||||
assert!(!s(12.0).matches(&s(11.7)));
|
||||
assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) }));
|
||||
assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) }));
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted.
|
||||
fn console(&mut self, lines: impl IntoIterator<Item = String>) {
|
||||
for l in lines {
|
||||
if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT {
|
||||
@@ -398,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
fn alert(&mut self, m: String) {
|
||||
/// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted.
|
||||
fn alert(&mut self, m: crate::js::Message) {
|
||||
if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT {
|
||||
self.run.out.alerts.push(m);
|
||||
} else {
|
||||
@@ -847,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names
|
||||
for ev in r.events_of(&som, "validate") {
|
||||
let Some(o) = r.run(&ev, &value)? else { continue };
|
||||
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
|
||||
r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")));
|
||||
r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::Zip;
|
||||
use super::as_html::esc;
|
||||
use super::layout::{Image, Item, Page, Table, body_size, items};
|
||||
use super::*;
|
||||
|
||||
/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0).
|
||||
pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::as_docx::rgb8;
|
||||
use super::as_html::allowed;
|
||||
use super::layout::{Block, Item, Page, count_lookup_steps, items};
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn rtf_text(s: &str) -> String {
|
||||
let mut o = String::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A paragraph of a page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod pptx;
|
||||
mod zip;
|
||||
|
||||
|
||||
@@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) ->
|
||||
|
||||
/// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from
|
||||
/// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
struct TriggerUpdate<'a> {
|
||||
trigger: Trigger,
|
||||
replacement: Option<&'a FieldAction>,
|
||||
previous: Option<Object>,
|
||||
}
|
||||
|
||||
fn replace_trigger(
|
||||
doc: &mut Document,
|
||||
pages: &[pdfcraft_cos::ObjRef],
|
||||
scripts: &mut pdfcraft_cos::DecodeBudget,
|
||||
t: Trigger,
|
||||
new: Option<&FieldAction>,
|
||||
old: Option<Object>,
|
||||
update: TriggerUpdate<'_>,
|
||||
aa: &mut Dict,
|
||||
a_entry: &mut Option<Object>,
|
||||
) -> Result<(), FormError> {
|
||||
let TriggerUpdate { trigger: t, replacement: new, previous: old } = update;
|
||||
let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts));
|
||||
if new.is_some() && new == old_action.as_ref() {
|
||||
return Ok(());
|
||||
@@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
|
||||
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
|
||||
Some(k) => aa.get(k).cloned(),
|
||||
};
|
||||
replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?;
|
||||
replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?;
|
||||
}
|
||||
doc.update_dict(w.obj, |d| {
|
||||
match &a_entry {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::calc::{js_tokens, notation_js, notation_range};
|
||||
use super::format::{DATE_PRESETS, TIME_PRESETS};
|
||||
use super::*;
|
||||
use super::{CalcOp, Calculate, Format, Validate};
|
||||
|
||||
// ── parsing scripts ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String {
|
||||
}
|
||||
|
||||
/// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box
|
||||
/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt.
|
||||
/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose
|
||||
/// wrapped lines fit the height. The page appearance and the on-screen editor share this.
|
||||
/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text
|
||||
/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is
|
||||
/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline
|
||||
/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page
|
||||
/// appearance and the on-screen editor share this.
|
||||
pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 {
|
||||
let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 };
|
||||
auto_text_size_by(
|
||||
@@ -54,6 +56,17 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt
|
||||
)
|
||||
}
|
||||
|
||||
/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside
|
||||
/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance
|
||||
/// and the on-screen editor share this.
|
||||
pub fn comb_auto_text_size(height: f64) -> f64 {
|
||||
if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 }
|
||||
}
|
||||
|
||||
/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side
|
||||
/// ISO 32000-1 Annex C recommends.
|
||||
pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0;
|
||||
|
||||
/// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on
|
||||
/// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its
|
||||
/// own font, whose widths and line breaks differ from the page's.
|
||||
@@ -67,7 +80,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i
|
||||
}
|
||||
return size;
|
||||
}
|
||||
let size = (height / 1.15).clamp(4.0, 12.0);
|
||||
let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0);
|
||||
let tw = line_width(size);
|
||||
if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size }
|
||||
}
|
||||
@@ -109,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox {
|
||||
size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size),
|
||||
multiline,
|
||||
password: f.has(flags::PASSWORD),
|
||||
comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0),
|
||||
comb: f.is_comb(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0);
|
||||
let comb = f.is_comb();
|
||||
if f.kind == FieldKind::Text && f.has(flags::MULTILINE) {
|
||||
if size == 0.0 {
|
||||
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
|
||||
@@ -840,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
}
|
||||
} else {
|
||||
if size == 0.0 {
|
||||
// Comb cells hold one character each, so only the height limits them.
|
||||
size = if comb {
|
||||
((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0)
|
||||
comb_auto_text_size(height - 2.0 * pad)
|
||||
} else {
|
||||
auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of)
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::read::{acroform, fields};
|
||||
use super::values::merged_appearance;
|
||||
use super::*;
|
||||
use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes};
|
||||
|
||||
/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`).
|
||||
pub fn need_appearances(doc: &Document) -> bool {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user