mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
Merge remote-tracking branch 'upstream/main' into fix/cropbox-text-clipping
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"
|
||||
|
||||
@@ -39,6 +39,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
+205
-14
@@ -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"
|
||||
@@ -4340,6 +4491,7 @@ dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
"rten",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -4391,7 +4543,7 @@ dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4554,7 +4706,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"png 0.18.1",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -4567,7 +4719,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -4888,12 +5040,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 +5608,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 +5646,7 @@ dependencies = [
|
||||
"rten-base",
|
||||
"rten-gemm",
|
||||
"rten-model-file",
|
||||
"rten-onnx",
|
||||
"rten-parallel",
|
||||
"rten-shape-inference",
|
||||
"rten-simd",
|
||||
@@ -5522,6 +5696,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 +6695,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 +6893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -7078,7 +7262,7 @@ dependencies = [
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
@@ -8052,6 +8236,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 +8563,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8386,6 +8576,7 @@ version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+10
-7
@@ -13,17 +13,17 @@ 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" }
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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)" },
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::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)]
|
||||
@@ -742,7 +742,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 +755,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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 -------------------------------------------------------------------------------
|
||||
|
||||
@@ -2030,21 +2030,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 +2069,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 +2109,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 +2122,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);
|
||||
}
|
||||
@@ -2809,6 +2829,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"),
|
||||
@@ -5260,6 +5286,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 +5315,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 +5330,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 +5939,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 +5955,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 +5977,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 +5989,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.
|
||||
@@ -7881,7 +7945,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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
+118
-75
@@ -6664,12 +6664,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 +6792,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]
|
||||
|
||||
@@ -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>)>>>;
|
||||
|
||||
+283
-249
@@ -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)]
|
||||
@@ -472,6 +472,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],
|
||||
@@ -490,66 +764,17 @@ pub(super) fn interpret(
|
||||
let mut stack: Vec<Ts> = 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" => {
|
||||
@@ -650,207 +875,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(())
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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,
|
||||
};
|
||||
|
||||
/// 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> {
|
||||
@@ -328,7 +331,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 +353,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())?,
|
||||
|
||||
+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.
|
||||
|
||||
+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 {
|
||||
|
||||
+147
-66
@@ -3068,7 +3068,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 +3082,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 +3108,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 +3419,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};
|
||||
@@ -8042,7 +8115,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 +8138,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();
|
||||
@@ -8656,69 +8737,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
bytes
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+126
-105
@@ -1,6 +1,6 @@
|
||||
use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets};
|
||||
use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
|
||||
use super::*;
|
||||
use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid};
|
||||
|
||||
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
|
||||
fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
|
||||
@@ -70,59 +70,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if let Some(acts) = &props.actions {
|
||||
crate::set_field_actions(doc, name, acts)?;
|
||||
}
|
||||
let mut new_name = name.to_string();
|
||||
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
|
||||
// A full name under the same parents only renames the field's own part (the editor
|
||||
// shows nested fields by their full name): it isn't moved, so it keeps its place and
|
||||
// goes on inheriting from its parents.
|
||||
let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) {
|
||||
(Some((parents, own)), Some((current, _))) if parents == current => own,
|
||||
_ => n,
|
||||
};
|
||||
if n.contains('.') {
|
||||
// A full name: the field moves under the parent fields it names (#2696).
|
||||
let parts = name_parts(n)?;
|
||||
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {
|
||||
return invalid(why);
|
||||
}
|
||||
let af = ensure_form(doc)?;
|
||||
// Checked before anything changes, so a refused move leaves the field as it was.
|
||||
let add = inherited(doc, f.obj);
|
||||
check_place(doc, af, f.obj, &parts, &add)?;
|
||||
// From here a failure (a write that `check_place` couldn't foresee) would leave the
|
||||
// field half moved in `doc`. The engine runs every edit on a copy of the document and
|
||||
// keeps it only when the edit succeeds (`Session::apply`), so the open document never
|
||||
// sees that.
|
||||
own_inherited(doc, f.obj, &add)?;
|
||||
detach(doc, f.obj)?;
|
||||
attach(doc, af, f.obj, &parts)?;
|
||||
new_name = n.to_string();
|
||||
} else {
|
||||
if n.is_empty() {
|
||||
return invalid("field names can't be empty");
|
||||
}
|
||||
// Rename the terminal part (the field's own /T). The parents' part stays, so the name
|
||||
// that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO
|
||||
// 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name.
|
||||
let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
|
||||
new_name = format!("{prefix}{n}");
|
||||
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) {
|
||||
return invalid(why);
|
||||
}
|
||||
// A node of that name beside it that isn't a terminal field, such as a parent
|
||||
// field left empty by a move.
|
||||
let af = ensure_form(doc)?;
|
||||
let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent"));
|
||||
if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) {
|
||||
return invalid(format!("a field named {new_name:?} already exists"));
|
||||
}
|
||||
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
|
||||
}
|
||||
// Calculations read the field by its new name, unless another field keeps the old one.
|
||||
if all.iter().filter(|x| x.name == f.name).count() == 1 {
|
||||
rename_in_calculations(doc, &all, &f.name, &new_name)?;
|
||||
}
|
||||
}
|
||||
let new_name = rename_field(doc, &all, &f, name, props)?;
|
||||
// The new default appearance when the size or the look changes.
|
||||
let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size);
|
||||
let new_da = match &props.look {
|
||||
@@ -248,51 +196,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(l) = &props.look {
|
||||
let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect());
|
||||
for w in &f.widgets {
|
||||
// Resolved first: an indirect /MK keeps its mark, rotation and other entries.
|
||||
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
|
||||
doc.update_dict(w.obj, |d| {
|
||||
match l.border {
|
||||
Some(c) => mk.set(b"BC".to_vec(), arr(c)),
|
||||
None => {
|
||||
mk.remove(b"BC");
|
||||
}
|
||||
}
|
||||
match l.fill {
|
||||
Some(c) => mk.set(b"BG".to_vec(), arr(c)),
|
||||
None => {
|
||||
mk.remove(b"BG");
|
||||
}
|
||||
}
|
||||
d.set(b"MK".to_vec(), Object::Dict(mk));
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0)));
|
||||
bs.set(b"S".to_vec(), Object::name(l.style.code()));
|
||||
if l.style == BorderStyle::Dashed {
|
||||
bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)]));
|
||||
}
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if let Some(style) = props.check_style {
|
||||
for w in &f.widgets {
|
||||
// Resolved first: an indirect /MK keeps its colours.
|
||||
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
|
||||
mk.set(b"CA".to_vec(), PdfString::text(style.code()));
|
||||
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
|
||||
}
|
||||
}
|
||||
if let Some((wi, r)) = props.rect {
|
||||
let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?;
|
||||
let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
|
||||
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE {
|
||||
return invalid("the field is too small");
|
||||
}
|
||||
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
|
||||
}
|
||||
apply_widget_style_props(doc, &f, name, props)?;
|
||||
if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() {
|
||||
if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
|
||||
return invalid("only text fields and drop-down lists have format, validate and calculate scripts");
|
||||
@@ -341,6 +245,127 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?;
|
||||
redraw_widgets(doc, &new_name, only)?;
|
||||
if props.calculate.is_some() {
|
||||
crate::recalculate(doc)?;
|
||||
}
|
||||
Ok(new_name)
|
||||
}
|
||||
|
||||
fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
let mut new_name = name.to_string();
|
||||
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
|
||||
// A full name under the same parents only renames the field's own part (the editor
|
||||
// shows nested fields by their full name): it isn't moved, so it keeps its place and
|
||||
// goes on inheriting from its parents.
|
||||
let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) {
|
||||
(Some((parents, own)), Some((current, _))) if parents == current => own,
|
||||
_ => n,
|
||||
};
|
||||
if n.contains('.') {
|
||||
// A full name: the field moves under the parent fields it names (#2696).
|
||||
let parts = name_parts(n)?;
|
||||
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {
|
||||
return invalid(why);
|
||||
}
|
||||
let af = ensure_form(doc)?;
|
||||
// Checked before anything changes, so a refused move leaves the field as it was.
|
||||
let add = inherited(doc, f.obj);
|
||||
check_place(doc, af, f.obj, &parts, &add)?;
|
||||
// From here a failure (a write that `check_place` couldn't foresee) would leave the
|
||||
// field half moved in `doc`. The engine runs every edit on a copy of the document and
|
||||
// keeps it only when the edit succeeds (`Session::apply`), so the open document never
|
||||
// sees that.
|
||||
own_inherited(doc, f.obj, &add)?;
|
||||
detach(doc, f.obj)?;
|
||||
attach(doc, af, f.obj, &parts)?;
|
||||
new_name = n.to_string();
|
||||
} else {
|
||||
if n.is_empty() {
|
||||
return invalid("field names can't be empty");
|
||||
}
|
||||
// Rename the terminal part (the field's own /T). The parents' part stays, so the name
|
||||
// that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO
|
||||
// 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name.
|
||||
let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
|
||||
new_name = format!("{prefix}{n}");
|
||||
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) {
|
||||
return invalid(why);
|
||||
}
|
||||
// A node of that name beside it that isn't a terminal field, such as a parent
|
||||
// field left empty by a move.
|
||||
let af = ensure_form(doc)?;
|
||||
let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent"));
|
||||
if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) {
|
||||
return invalid(format!("a field named {new_name:?} already exists"));
|
||||
}
|
||||
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
|
||||
}
|
||||
// Calculations read the field by its new name, unless another field keeps the old one.
|
||||
if all.iter().filter(|x| x.name == f.name).count() == 1 {
|
||||
rename_in_calculations(doc, all, &f.name, &new_name)?;
|
||||
}
|
||||
}
|
||||
Ok(new_name)
|
||||
}
|
||||
|
||||
fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> {
|
||||
if let Some(l) = &props.look {
|
||||
let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect());
|
||||
for w in &f.widgets {
|
||||
// Resolved first: an indirect /MK keeps its mark, rotation and other entries.
|
||||
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
|
||||
doc.update_dict(w.obj, |d| {
|
||||
match l.border {
|
||||
Some(c) => mk.set(b"BC".to_vec(), arr(c)),
|
||||
None => {
|
||||
mk.remove(b"BC");
|
||||
}
|
||||
}
|
||||
match l.fill {
|
||||
Some(c) => mk.set(b"BG".to_vec(), arr(c)),
|
||||
None => {
|
||||
mk.remove(b"BG");
|
||||
}
|
||||
}
|
||||
d.set(b"MK".to_vec(), Object::Dict(mk));
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0)));
|
||||
bs.set(b"S".to_vec(), Object::name(l.style.code()));
|
||||
if l.style == BorderStyle::Dashed {
|
||||
bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)]));
|
||||
}
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if let Some(style) = props.check_style {
|
||||
for w in &f.widgets {
|
||||
// Resolved first: an indirect /MK keeps its colours.
|
||||
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
|
||||
mk.set(b"CA".to_vec(), PdfString::text(style.code()));
|
||||
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
|
||||
}
|
||||
}
|
||||
if let Some((wi, r)) = props.rect {
|
||||
let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?;
|
||||
let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
|
||||
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE {
|
||||
return invalid("the field is too small");
|
||||
}
|
||||
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_late_widget_props(
|
||||
doc: &mut Document,
|
||||
f: &Field,
|
||||
props: &FieldProps,
|
||||
new_da: &str,
|
||||
rotated_rect: Option<Option<[f64; 4]>>,
|
||||
) -> Result<Option<usize>, FormError> {
|
||||
// A complete look replaces widget /DA overrides. Partial changes were patched above
|
||||
// against each override, including size-only changes that keep its font and colour.
|
||||
if props.look.is_some() {
|
||||
@@ -348,7 +373,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if w.obj != f.obj {
|
||||
doc.update_dict(w.obj, |d| {
|
||||
if d.contains(b"DA") {
|
||||
d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes()));
|
||||
d.set(b"DA".to_vec(), PdfString::literal(new_da.to_owned().into_bytes()));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
@@ -385,11 +410,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
// A label-only edit must not replace another widget's custom appearances.
|
||||
let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);
|
||||
redraw_widgets(doc, &new_name, only)?;
|
||||
if props.calculate.is_some() {
|
||||
crate::recalculate(doc)?;
|
||||
}
|
||||
Ok(new_name)
|
||||
Ok(only)
|
||||
}
|
||||
|
||||
/// Name field `old` `new` in each field's Calculate script that reads it
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::look::redraw_field;
|
||||
use super::tree::{add_to_page, attach, clash, detach, ensure_form, free_name, name_parts, top_names};
|
||||
use super::*;
|
||||
use super::{
|
||||
Dict, Document, Field, FieldKind, FormError, MIN_FIELD_SIDE, NewField, ObjRef, Object, PdfString, Widget, appearance, fields, flags, invalid,
|
||||
page_refs, rgb,
|
||||
};
|
||||
|
||||
fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb};
|
||||
|
||||
/// The Appearance tab: borders, colours and text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid};
|
||||
|
||||
/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed.
|
||||
pub(crate) fn ensure_form(doc: &mut Document) -> Result<ObjRef, FormError> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::tab_order::rank_tabs;
|
||||
use super::*;
|
||||
use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of};
|
||||
|
||||
/// At most this many `/State` entries of a set-layer-visibility action are read.
|
||||
const MAX_LAYER_STATE: usize = 1024;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::read::{fields, page_refs};
|
||||
use super::*;
|
||||
use super::{Document, Field, FormError, ObjRef, Object, invalid};
|
||||
|
||||
/// A page's tab order (`/Tabs`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
+123
-80
@@ -3601,13 +3601,17 @@ fn dotted_field_names_are_made_with_parent_fields() {
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
invalid_dotted_field_names_are_rejected(&mut doc, &text);
|
||||
}
|
||||
|
||||
fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) {
|
||||
for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] {
|
||||
assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}");
|
||||
assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}");
|
||||
assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}");
|
||||
assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}");
|
||||
}
|
||||
let deep = vec!["p"; 33].join(".");
|
||||
assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep");
|
||||
assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken");
|
||||
assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep");
|
||||
assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken");
|
||||
}
|
||||
|
||||
/// Review on #2957: a parent left empty by a move keeps its name from the next default name
|
||||
@@ -3801,90 +3805,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr
|
||||
/// - Helv, TiRo and Cour are there.
|
||||
#[test]
|
||||
fn every_shape_of_form_fonts_ends_as_objects() {
|
||||
let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>");
|
||||
let mut cases = 0;
|
||||
for dr_shape in ["missing", "inline", "object"] {
|
||||
for font_shape in ["missing", "inline", "object", "number"] {
|
||||
for helv in ["missing", "inline", "object", "dangling", "null", "number"] {
|
||||
for mine in ["missing", "inline", "object"] {
|
||||
if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") {
|
||||
continue;
|
||||
}
|
||||
if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") {
|
||||
let case = FontShapeCase::new(dr_shape, font_shape, helv, mine);
|
||||
if !case.is_valid() {
|
||||
continue;
|
||||
}
|
||||
cases += 1;
|
||||
let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}");
|
||||
let mut entries = String::new();
|
||||
match helv {
|
||||
"inline" => entries += &format!("/Helv {} ", font("Arial")),
|
||||
"object" => entries += "/Helv 7 0 R ",
|
||||
"dangling" => entries += "/Helv 99 0 R ",
|
||||
"null" => entries += "/Helv null ",
|
||||
"number" => entries += "/Helv 3 ",
|
||||
_ => {}
|
||||
}
|
||||
match mine {
|
||||
"inline" => entries += &format!("/Mine {} ", font("Symbol")),
|
||||
"object" => entries += "/Mine 8 0 R ",
|
||||
_ => {}
|
||||
}
|
||||
let fonts_value = match font_shape {
|
||||
"inline" => format!("<< {entries}>>"),
|
||||
"object" => "6 0 R".into(),
|
||||
"number" => "7".into(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") };
|
||||
let dr_value = match dr_shape {
|
||||
"inline" => dr_dict.clone(),
|
||||
"object" => "5 0 R".into(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") };
|
||||
let mut doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
af,
|
||||
if dr_shape == "object" { dr_dict } else { "null".into() },
|
||||
if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() },
|
||||
font("Helvetica-Oblique"),
|
||||
font("ZapfDingbats"),
|
||||
]);
|
||||
add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name"))
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let doc = reopen(&doc);
|
||||
let af = acroform(&doc).unwrap();
|
||||
let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR")));
|
||||
let fonts = dr_fonts(&doc);
|
||||
for (name, value) in fonts.iter() {
|
||||
assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name));
|
||||
}
|
||||
for name in ["Helv", "TiRo", "Cour"] {
|
||||
assert!(fonts.contains(name.as_bytes()), "{what}: no {name}");
|
||||
}
|
||||
let base =
|
||||
|name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec)));
|
||||
match helv {
|
||||
"inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"),
|
||||
"object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"),
|
||||
"dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"),
|
||||
"null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"),
|
||||
"number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"),
|
||||
_ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"),
|
||||
}
|
||||
match mine {
|
||||
"inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"),
|
||||
"object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"),
|
||||
_ => assert!(!fonts.contains(b"Mine"), "{what}"),
|
||||
}
|
||||
if dr_shape == "object" {
|
||||
assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object");
|
||||
}
|
||||
if font_shape == "object" {
|
||||
assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object");
|
||||
}
|
||||
assert_font_shape_case(&case);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3892,6 +3823,118 @@ fn every_shape_of_form_fonts_ends_as_objects() {
|
||||
assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried");
|
||||
}
|
||||
|
||||
struct FontShapeCase<'a> {
|
||||
dr: &'a str,
|
||||
fonts: &'a str,
|
||||
helv: &'a str,
|
||||
mine: &'a str,
|
||||
description: String,
|
||||
}
|
||||
|
||||
impl<'a> FontShapeCase<'a> {
|
||||
fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self {
|
||||
Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") }
|
||||
}
|
||||
|
||||
fn is_valid(&self) -> bool {
|
||||
let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing";
|
||||
let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing");
|
||||
!(self.dr == "missing" && !only_default_dr || invalid_fonts)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_font_shape_case(case: &FontShapeCase<'_>) {
|
||||
let mut doc = font_shape_document(case);
|
||||
add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name"))
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", case.description));
|
||||
let doc = reopen(&doc);
|
||||
let acro = acroform(&doc).unwrap();
|
||||
let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description)));
|
||||
let fonts = dr_fonts(&doc);
|
||||
assert_font_entries_are_references(&fonts, &case.description);
|
||||
assert_required_form_fonts(&fonts, &case.description);
|
||||
assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case);
|
||||
}
|
||||
|
||||
fn font_shape_document(case: &FontShapeCase<'_>) -> Document {
|
||||
let mut entries = String::new();
|
||||
match case.helv {
|
||||
"inline" => entries += &format!("/Helv {} ", form_font("Arial")),
|
||||
"object" => entries += "/Helv 7 0 R ",
|
||||
"dangling" => entries += "/Helv 99 0 R ",
|
||||
"null" => entries += "/Helv null ",
|
||||
"number" => entries += "/Helv 3 ",
|
||||
_ => {}
|
||||
}
|
||||
match case.mine {
|
||||
"inline" => entries += &format!("/Mine {} ", form_font("Symbol")),
|
||||
"object" => entries += "/Mine 8 0 R ",
|
||||
_ => {}
|
||||
}
|
||||
let fonts_value = match case.fonts {
|
||||
"inline" => format!("<< {entries}>>"),
|
||||
"object" => "6 0 R".into(),
|
||||
"number" => "7".into(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") };
|
||||
let dr_value = match case.dr {
|
||||
"inline" => dr_dict.clone(),
|
||||
"object" => "5 0 R".into(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") };
|
||||
document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
acroform,
|
||||
if case.dr == "object" { dr_dict } else { "null".into() },
|
||||
if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() },
|
||||
form_font("Helvetica-Oblique"),
|
||||
form_font("ZapfDingbats"),
|
||||
])
|
||||
}
|
||||
|
||||
fn form_font(base: &str) -> String {
|
||||
format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>")
|
||||
}
|
||||
|
||||
fn assert_font_entries_are_references(fonts: &Dict, description: &str) {
|
||||
for (name, value) in fonts.iter() {
|
||||
assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_required_form_fonts(fonts: &Dict, description: &str) {
|
||||
for name in ["Helv", "TiRo", "Cour"] {
|
||||
assert!(fonts.contains(name.as_bytes()), "{description}: no {name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) {
|
||||
let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec)));
|
||||
match case.helv {
|
||||
"inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description),
|
||||
"object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description),
|
||||
"dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description),
|
||||
"null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description),
|
||||
"number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description),
|
||||
_ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description),
|
||||
}
|
||||
match case.mine {
|
||||
"inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description),
|
||||
"object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description),
|
||||
_ => assert!(!fonts.contains(b"Mine"), "{}", case.description),
|
||||
}
|
||||
if case.dr == "object" {
|
||||
assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description);
|
||||
}
|
||||
if case.fonts == "object" {
|
||||
assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form
|
||||
/// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A
|
||||
/// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::appearances::{declares_pdfa, need_appearances};
|
||||
use super::read::{acroform, field_flags, fields, page_refs};
|
||||
use super::*;
|
||||
use super::{
|
||||
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author,
|
||||
calc_order, flags, invalid, name_obj, scripting,
|
||||
};
|
||||
|
||||
// ── writing ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request};
|
||||
use super::*;
|
||||
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
|
||||
|
||||
macro_rules! getter {
|
||||
($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::host::{arg, error, s, text};
|
||||
use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION};
|
||||
use super::*;
|
||||
use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc};
|
||||
|
||||
/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups
|
||||
/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.`
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::CLOCK_EVERY;
|
||||
use super::ast::{Expr, Idx};
|
||||
use super::value::{Val, num_text, parse_num};
|
||||
use super::*;
|
||||
use super::{
|
||||
HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, instances,
|
||||
resolve_som,
|
||||
};
|
||||
|
||||
impl Interp<'_> {
|
||||
pub(super) fn step(&mut self) -> R<()> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Func, Rc};
|
||||
|
||||
// ── syntax ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, pars
|
||||
use super::parser::parse;
|
||||
use super::value::{Val, fixed, is_numeric_text, parse_num};
|
||||
use super::words::word_num;
|
||||
use super::*;
|
||||
use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit};
|
||||
|
||||
impl Interp<'_> {
|
||||
/// A built-in function: its result is never a string past [`MAX_STRING`], and work on
|
||||
@@ -22,6 +22,15 @@ impl Interp<'_> {
|
||||
fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R<Val> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let n = |v: f64| Ok(Val::Num(v));
|
||||
if let Some(value) = self.date_builtin(&lower, args)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.financial_builtin(&lower, args)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.unit_builtin(&lower, args)? {
|
||||
return Ok(value);
|
||||
}
|
||||
Ok(match lower.as_str() {
|
||||
// ── arithmetic
|
||||
"abs" => return n(self.arg_num(args, 0)?.abs()),
|
||||
@@ -271,7 +280,24 @@ impl Interp<'_> {
|
||||
let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect();
|
||||
parse_num(&cleaned).map_or(Val::Null, Val::Num)
|
||||
}
|
||||
// ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight)
|
||||
"ref" => {
|
||||
let Some(e) = args.first() else { return Ok(Val::Null) };
|
||||
self.eval(e)?
|
||||
}
|
||||
"eval" => {
|
||||
let src = self.arg_str(args, 0)?;
|
||||
self.charge((src.len() / 16) as u64)?;
|
||||
let body = parse(&src)?;
|
||||
let (_, v) = self.block(&body)?;
|
||||
v
|
||||
}
|
||||
"get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")),
|
||||
_ => return Err(format!("unknown function {name}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R<Option<Val>> {
|
||||
let value = match lower {
|
||||
"date" => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -304,7 +330,7 @@ impl Interp<'_> {
|
||||
let days = self.arg_num(args, 0)?;
|
||||
let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() };
|
||||
if !days.is_finite() || days.abs() >= MAX_DAYS {
|
||||
return Ok(Val::Null);
|
||||
return Ok(Some(Val::Null));
|
||||
}
|
||||
let (y, m, d) = civil_from_days(days as i64);
|
||||
Val::Str(format_date(y, m, d, &pic))
|
||||
@@ -326,7 +352,7 @@ impl Interp<'_> {
|
||||
"num2time" | "num2gmtime" => {
|
||||
let ms = self.arg_num(args, 0)?;
|
||||
if !ms.is_finite() || ms < 0.0 {
|
||||
return Ok(Val::Null);
|
||||
return Ok(Some(Val::Null));
|
||||
}
|
||||
let s = (ms / 1000.0) as u64;
|
||||
Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60))
|
||||
@@ -344,7 +370,13 @@ impl Interp<'_> {
|
||||
parts.next().and_then(|p| p.trim().parse::<f64>().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0);
|
||||
Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round())
|
||||
}
|
||||
// ── financial
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R<Option<Val>> {
|
||||
let value = match lower {
|
||||
"fv" => {
|
||||
let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?);
|
||||
Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) })
|
||||
@@ -428,7 +460,13 @@ impl Interp<'_> {
|
||||
}
|
||||
Val::Num((lo + hi) / 2.0 * 12.0)
|
||||
}
|
||||
// ── units and references
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R<Option<Val>> {
|
||||
let value = match lower {
|
||||
"unittype" => {
|
||||
let s = self.arg_str(args, 0)?;
|
||||
let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect();
|
||||
@@ -459,19 +497,8 @@ impl Interp<'_> {
|
||||
let target = if to.trim().is_empty() { unit.clone() } else { to };
|
||||
Val::Num(v / per_in(&unit) * per_in(&target))
|
||||
}
|
||||
"ref" => {
|
||||
let Some(e) = args.first() else { return Ok(Val::Null) };
|
||||
self.eval(e)?
|
||||
}
|
||||
"eval" => {
|
||||
let src = self.arg_str(args, 0)?;
|
||||
self.charge((src.len() / 16) as u64)?;
|
||||
let body = parse(&src)?;
|
||||
let (_, v) = self.block(&body)?;
|
||||
v
|
||||
}
|
||||
"get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")),
|
||||
_ => return Err(format!("unknown function {name}")),
|
||||
})
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::ast::{Expr, Stmt};
|
||||
use super::value::{Val, is_numeric_text, num_text};
|
||||
use super::*;
|
||||
use super::{
|
||||
CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip,
|
||||
descendants_named, instances, reset_names, resolve_som,
|
||||
};
|
||||
|
||||
impl Interp<'_> {
|
||||
// ── expressions
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::MAX_STRING;
|
||||
|
||||
// ── lexer ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::ast::{Expr, Idx, Stmt};
|
||||
use super::lexer::{Tok, Token, lex};
|
||||
use super::*;
|
||||
use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc};
|
||||
|
||||
struct Parser {
|
||||
toks: Vec<Token>,
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie
|
||||
use super::format::{printd, printf, printx, scand};
|
||||
use super::host::{arg, function, host, s, text, text_line};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request};
|
||||
use super::*;
|
||||
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
|
||||
|
||||
// ── app, util, console ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::model::{DocInfo, FieldState, FieldType, Request};
|
||||
use super::run::MAX_ARRAY_LENGTH;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell,
|
||||
xfa,
|
||||
};
|
||||
|
||||
pub(crate) struct Host {
|
||||
pub(crate) fields: Arc<Vec<FieldState>>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::globals::{event_object, install};
|
||||
use super::host::{Host, Shared, text};
|
||||
use super::model::{DocInfo, Event, FieldState, Limits, Outcome};
|
||||
use super::*;
|
||||
use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string};
|
||||
|
||||
/// Longest script, in bytes.
|
||||
pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024;
|
||||
|
||||
+123
-50
@@ -1017,6 +1017,14 @@ mod formcalc {
|
||||
|
||||
#[test]
|
||||
fn built_in_functions_cover_the_common_set() {
|
||||
assert_numeric_and_logical_builtins();
|
||||
assert_string_builtins();
|
||||
assert_format_and_date_builtins();
|
||||
assert_financial_and_unit_builtins();
|
||||
assert_builtin_errors();
|
||||
}
|
||||
|
||||
fn assert_numeric_and_logical_builtins() {
|
||||
assert_eq!(calc("Abs(-2.5)"), "2.5");
|
||||
assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b");
|
||||
assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb");
|
||||
@@ -1034,6 +1042,9 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Within(5, 1, 10)"#), "1");
|
||||
assert_eq!(calc(r#"HasValue("")"#), "0");
|
||||
assert_eq!(calc(r#"HasValue("x")"#), "1");
|
||||
}
|
||||
|
||||
fn assert_string_builtins() {
|
||||
assert_eq!(calc(r#"At("abcd", "c")"#), "3");
|
||||
assert_eq!(calc(r#"Left("hello", 2)"#), "he");
|
||||
assert_eq!(calc(r#"Right("hello", 2)"#), "lo");
|
||||
@@ -1050,6 +1061,11 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two");
|
||||
assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents");
|
||||
assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent");
|
||||
assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c");
|
||||
assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b");
|
||||
}
|
||||
|
||||
fn assert_format_and_date_builtins() {
|
||||
assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50");
|
||||
assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00");
|
||||
assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024");
|
||||
@@ -1065,6 +1081,9 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250");
|
||||
assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000");
|
||||
assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01");
|
||||
}
|
||||
|
||||
fn assert_financial_and_unit_builtins() {
|
||||
assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49");
|
||||
assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25");
|
||||
assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51");
|
||||
@@ -1073,8 +1092,9 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1");
|
||||
assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1");
|
||||
assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72");
|
||||
assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c");
|
||||
assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b");
|
||||
}
|
||||
|
||||
fn assert_builtin_errors() {
|
||||
assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network"));
|
||||
assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function"));
|
||||
}
|
||||
@@ -1147,6 +1167,13 @@ s"#
|
||||
|
||||
#[test]
|
||||
fn form_objects_resolve_assign_and_record_effects() {
|
||||
assert_formcalc_reads_fields_and_rows();
|
||||
assert_formcalc_reads_runtime_properties();
|
||||
assert_formcalc_records_primary_effects();
|
||||
assert_formcalc_updates_instances_and_records_requests();
|
||||
}
|
||||
|
||||
fn assert_formcalc_reads_fields_and_rows() {
|
||||
// Fields deref to their values; `$` is the current field.
|
||||
assert_eq!(calc("qty * price"), "10");
|
||||
assert_eq!(calc("$.name"), "total");
|
||||
@@ -1170,7 +1197,6 @@ s"#
|
||||
assert_eq!(in_row("Sum(row[*].amount)"), "15");
|
||||
assert_eq!(in_row("row[1].amount"), "5");
|
||||
assert_eq!(in_row("row[-1].amount"), "10");
|
||||
// Past the ends there is no object, so a property of it is an error (as in Acrobat).
|
||||
let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate");
|
||||
assert!(past.error.unwrap().contains("no object"));
|
||||
assert_eq!(in_row("Exists(row[-5])"), "0");
|
||||
@@ -1182,12 +1208,18 @@ s"#
|
||||
assert_eq!(calc("table._row.count"), "2");
|
||||
assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1");
|
||||
assert_eq!(calc("HasValue(total)"), "0");
|
||||
}
|
||||
|
||||
fn assert_formcalc_reads_runtime_properties() {
|
||||
assert_eq!(calc("$host.numPages"), "3");
|
||||
assert_eq!(calc("$layout.pageCount()"), "3");
|
||||
assert_eq!(calc("$event.name"), "calculate");
|
||||
assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5");
|
||||
assert_eq!(calc("$.border.fill.color.value"), "");
|
||||
assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name"));
|
||||
}
|
||||
|
||||
fn assert_formcalc_records_primary_effects() {
|
||||
// Assignment to a field (by name and by path), a property and a variable.
|
||||
let o = go(
|
||||
r#"page1.qty = 6
|
||||
@@ -1215,7 +1247,9 @@ table._row.count"#,
|
||||
XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() },
|
||||
]
|
||||
);
|
||||
// Instance managers: remove, setInstances, count assignment, and the floor of one.
|
||||
}
|
||||
|
||||
fn assert_formcalc_updates_instances_and_records_requests() {
|
||||
let o = go(
|
||||
"table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count",
|
||||
"form1[0].page1[0].go[0]",
|
||||
@@ -1238,7 +1272,7 @@ $host.gotoURL("https://example.org")"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.effects,
|
||||
vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())]
|
||||
vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1279,58 +1313,79 @@ $host.gotoURL("https://example.org")"#,
|
||||
|
||||
#[test]
|
||||
fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() {
|
||||
let total = "form1[0].page1[0].total[0]";
|
||||
let fast = Limits { loop_iterations: 10_000, recursion: 32 };
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast);
|
||||
assert!(run("while (1) do endwhile").error.unwrap().contains("looped"));
|
||||
assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped"));
|
||||
assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested"));
|
||||
assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some());
|
||||
assert_hostile_control_flow_is_bounded();
|
||||
assert_hostile_numeric_inputs_are_safe();
|
||||
assert_hostile_outputs_are_bounded();
|
||||
assert_hostile_nesting_is_bounded();
|
||||
assert_hostile_deadlines_and_parse_errors();
|
||||
assert_hostile_instance_growth_is_bounded();
|
||||
assert_hostile_message_count_is_bounded();
|
||||
assert_hostile_small_stack_is_safe();
|
||||
}
|
||||
|
||||
fn assert_hostile_control_flow_is_bounded() {
|
||||
assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped"));
|
||||
assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped"));
|
||||
assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested"));
|
||||
assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some());
|
||||
// A 120k-term chain is refused at parse time (its tree would be as deep as it is long).
|
||||
assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long"));
|
||||
assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long"));
|
||||
assert!(run("nothing.x = 1").error.unwrap().contains("unknown name"));
|
||||
assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing"));
|
||||
assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long"));
|
||||
assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long"));
|
||||
assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name"));
|
||||
assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing"));
|
||||
// Values go into fields only: a subform or the form itself is refused, nothing is recorded.
|
||||
let o = run("details = \"junk\"");
|
||||
let o = run_hostile_formcalc("details = \"junk\"");
|
||||
assert!(o.error.unwrap().contains("not a field"));
|
||||
assert!(o.effects.is_empty());
|
||||
assert!(run("$form = \"junk\"").error.unwrap().contains("not a field"));
|
||||
assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field"));
|
||||
}
|
||||
|
||||
fn assert_hostile_numeric_inputs_are_safe() {
|
||||
// Hostile numbers into the string functions.
|
||||
assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX"));
|
||||
assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc"));
|
||||
assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some(""));
|
||||
assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some(""));
|
||||
assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none());
|
||||
assert!(run(r#"Round(1e300, 1e300)"#).error.is_none());
|
||||
assert!(run(r#"Choose(1e300, 1)"#).error.is_none());
|
||||
assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX"));
|
||||
assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc"));
|
||||
assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some(""));
|
||||
assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some(""));
|
||||
assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none());
|
||||
assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none());
|
||||
assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none());
|
||||
}
|
||||
|
||||
fn assert_hostile_outputs_are_bounded() {
|
||||
// Strings can't grow past the cap through any function, and messages and values are clipped.
|
||||
assert!(
|
||||
run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)")
|
||||
run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)")
|
||||
.error
|
||||
.unwrap()
|
||||
.contains("too long")
|
||||
);
|
||||
assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long"));
|
||||
let o = run("$host.messageBox(Space(100000))");
|
||||
assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long"));
|
||||
let o = run_hostile_formcalc("$host.messageBox(Space(100000))");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len());
|
||||
let o = run("qty = Space(100000)");
|
||||
let o = run_hostile_formcalc("qty = Space(100000)");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len());
|
||||
assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes);
|
||||
}
|
||||
|
||||
fn assert_hostile_nesting_is_bounded() {
|
||||
// A comment of dashes is not deep nesting.
|
||||
assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2"));
|
||||
assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2"));
|
||||
// Parentheses don't reset the chain count: a tree is never deeper than the cap.
|
||||
let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string();
|
||||
for _ in 0..30 {
|
||||
nested = format!("({nested}{})", "+1".repeat(40));
|
||||
}
|
||||
assert!(run(&nested).error.unwrap().contains("too long"));
|
||||
assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32"));
|
||||
assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long"));
|
||||
assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32"));
|
||||
// Nested statements inside recursion count against the depth limit.
|
||||
let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20));
|
||||
assert!(run(&deep).error.unwrap().contains("nested"));
|
||||
assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested"));
|
||||
}
|
||||
|
||||
fn assert_hostile_deadlines_and_parse_errors() {
|
||||
let total = "form1[0].page1[0].total[0]";
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
// A deadline already passed stops the script at its first look at the clock.
|
||||
let o = crate::formcalc::run_formcalc_at(
|
||||
"for i = 1 upto 100000 do Eval(\"1\") endfor",
|
||||
@@ -1341,29 +1396,40 @@ $host.gotoURL("https://example.org")"#,
|
||||
Some(std::time::Duration::ZERO),
|
||||
);
|
||||
assert!(o.error.unwrap().contains("too long"));
|
||||
assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some());
|
||||
assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long"));
|
||||
assert!(run("Space(1e12)").error.is_none());
|
||||
assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long"));
|
||||
assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None);
|
||||
assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None);
|
||||
assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None);
|
||||
assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some(""));
|
||||
assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name"));
|
||||
assert!(run("\"unterminated").error.unwrap().contains("unterminated"));
|
||||
assert!(run("1 ) 2").error.is_some());
|
||||
assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none());
|
||||
assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some());
|
||||
assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long"));
|
||||
assert!(run_hostile_formcalc("Space(1e12)").error.is_none());
|
||||
assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long"));
|
||||
assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None);
|
||||
assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None);
|
||||
assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None);
|
||||
assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some(""));
|
||||
assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name"));
|
||||
assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated"));
|
||||
assert!(run_hostile_formcalc("1 ) 2").error.is_some());
|
||||
assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none());
|
||||
}
|
||||
|
||||
fn assert_hostile_instance_growth_is_bounded() {
|
||||
// Adding rows forever stops at the instance cap with the effects it managed.
|
||||
let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count");
|
||||
let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count");
|
||||
assert!(o.error.unwrap().contains("looped"));
|
||||
assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len());
|
||||
let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count");
|
||||
let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count");
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.result.as_deref(), Some("1002"));
|
||||
}
|
||||
|
||||
fn assert_hostile_message_count_is_bounded() {
|
||||
// Message boxes past the cap are dropped and noted.
|
||||
let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor");
|
||||
let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor");
|
||||
assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100);
|
||||
assert!(!o.notes.is_empty());
|
||||
}
|
||||
|
||||
fn assert_hostile_small_stack_is_safe() {
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
// On a small stack (the wasm build's), the limits stop the script before the stack does.
|
||||
let small = std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
@@ -1400,6 +1466,13 @@ $host.gotoURL("https://example.org")"#,
|
||||
);
|
||||
assert!(o.abandoned || o.error.is_some(), "{o:?}");
|
||||
}
|
||||
|
||||
fn run_hostile_formcalc(script: &str) -> XfaOutcome {
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
let fast = Limits { loop_iterations: 10_000, recursion: 32 };
|
||||
run_formcalc(script, &ev, &doc, &form(), fast)
|
||||
}
|
||||
}
|
||||
|
||||
mod session {
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::host::host;
|
||||
use super::instances::instance_manager;
|
||||
use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names};
|
||||
use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink};
|
||||
use super::*;
|
||||
use super::{
|
||||
Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand,
|
||||
text,
|
||||
};
|
||||
|
||||
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::model::{
|
||||
MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value,
|
||||
effect_key, payload,
|
||||
};
|
||||
use super::*;
|
||||
use super::{Context, HashMap, JsResult, RefCell, error};
|
||||
|
||||
/// One node of the flattened form, shared by the JavaScript and FormCalc engines.
|
||||
pub(crate) struct HNode {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user