mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 08:51:27 +00:00
Merge main into fix/undo-in-open-text-editor
Conflicts: - content_ui.rs: main's added-text editor (alignment, character spacing) kept whole; the box takes input_id() instead of an id_salt, and its Undo history is reset per document by forget_typing_before. - tests/it/editing.rs: both sides' new tests kept. #3546's TextDraft literals (editing.rs, edit_text_ui.rs tests) take typing_from: None.
This commit is contained in:
commit
86e61bd669
296 files changed
+23442
-5064
No files matched your search
+72
-135
@@ -1,152 +1,89 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions. Nightly fuzzing is in fuzz.yml.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it.
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "17 3 * * *" # nightly fuzzing
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
# The token is only used to check out the code: no job writes to the repository.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end. Adding
|
||||
# any label but `full-ci` gets a group of its own, so it neither runs the gate nor cancels a run.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
CARGO_PROFILE_TEST_DEBUG: line-tables-only
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds,
|
||||
# and release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
# The shared gate doesn't check where git dependencies come from; release.yml runs the same job.
|
||||
git-sources:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on
|
||||
# Linux; Windows tests the build without it. See craftrules standards/fonts.md.
|
||||
- name: Check out craft-fonts (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
|
||||
- name: Configure craft-fonts (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
persist-credentials: false
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd) and the oracles
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev \
|
||||
qpdf poppler-utils
|
||||
|
||||
# The independent oracles (crates/oracle) are only worth having when they run. With this
|
||||
# set, a missing one fails the job instead of printing a skip notice that reads as a pass.
|
||||
# Only the tools the job actually uses are installed above.
|
||||
- name: Require the oracles (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Require Windows executable resources
|
||||
if: runner.os == 'Windows'
|
||||
run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1'
|
||||
shell: pwsh
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Test
|
||||
if: runner.os != 'Linux'
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Test (with craft-fonts)
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Layering
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask layers
|
||||
|
||||
- name: Assets (licences and attribution)
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask assets
|
||||
|
||||
- name: CLI without the MCP server still builds
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo check -p pdfcraft-cli --no-default-features
|
||||
|
||||
wasm:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: wasm32
|
||||
- name: Check library crates for wasm32
|
||||
run: cargo xtask wasm
|
||||
|
||||
deny:
|
||||
name: dependency licences and advisories
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
|
||||
with:
|
||||
command: check
|
||||
|
||||
fuzz:
|
||||
name: fuzz (mutation, 15 min)
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: fuzz
|
||||
- name: Fetch the pdf.js corpus (seeds)
|
||||
run: cargo xtask corpus
|
||||
- name: Fuzz
|
||||
run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }}
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: fuzz-findings
|
||||
path: fuzz-out/findings
|
||||
if-no-files-found: ignore
|
||||
gate:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Windows on main, manual runs and pull requests labelled `full-ci`. No macOS on PRs or main:
|
||||
# the org's 5 macOS slots are kept for release builds, and release.yml builds macOS.
|
||||
os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }}
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml.
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
# egui/winit/rfd, and the independent oracles (crates/oracle).
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev qpdf poppler-utils
|
||||
# cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI.
|
||||
tools: cargo-nextest@0.9.149,cargo-deny@0.20.2
|
||||
# A missing oracle fails the job instead of printing a skip notice that reads as a pass.
|
||||
linux-env: PDFCRAFT_ORACLES_REQUIRED=1
|
||||
windows-env: PDFCRAFT_REQUIRE_WINRES=1
|
||||
# Windows checks what differs by platform: the build (with resources) and the tests.
|
||||
windows-command: |
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Fuzz
|
||||
|
||||
# Mutation fuzzing of open/render/edit/save (`cargo xtask fuzz`), nightly and on demand. Findings
|
||||
# are uploaded as an artifact; each real one becomes a synthetic regression test.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# The token is only used to check out the code: no job writes to the repository.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
concurrency:
|
||||
group: fuzz-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fuzz:
|
||||
name: fuzz (mutation, 15 min)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: fuzz
|
||||
- name: Fetch the pdf.js corpus (seeds)
|
||||
run: cargo xtask corpus
|
||||
- name: Fuzz
|
||||
run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }}
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: fuzz-findings
|
||||
path: fuzz-out/findings
|
||||
if-no-files-found: ignore
|
||||
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
@@ -20,10 +20,10 @@ An **asset** is any non-code material that the project includes, ships, embeds,
|
||||
|
||||
- **Never** use icons, images, artwork, logos, UI graphics, fonts, stamps, cursors, sample documents or any other asset taken from an Adobe product, installer, website, document or package.
|
||||
- "Adobe product" includes Acrobat, Reader, Photoshop, Illustrator, Premiere, Lightroom and Creative Cloud, including the fonts they install (for example Minion, Myriad, Kozuka and the Adobe CJK families).
|
||||
- This holds even when Adobe publishes the material under an open licence, if it is **visual design**. For example, **do not** use Source Han, Source Serif or Source Sans, or Noto CJK, which is Source Han rebranded.
|
||||
- Typefaces released under the SIL Open Font License are allowed like any other OFL font, with an `ATTRIBUTION.toml` entry, whoever designed them: for example Noto CJK and Source Han, taken from their open-source releases. The copies an Adobe product installs stay banned.
|
||||
- **Never** recreate, trace, redraw or closely imitate an Adobe icon or graphic. Our icons come from Lucide, and our visual design is our own.
|
||||
- Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs.
|
||||
- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
|
||||
- Apart from those OFL typefaces, the only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
|
||||
- The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency.
|
||||
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_metrics.rs` (approved by the owner, 2026-10-08; that file holds the data alone, so the code reading it can change without re-attesting the table), which is what generated appearances measure with and what survives hayro being retired.
|
||||
|
||||
@@ -54,16 +54,17 @@ Anything else is forbidden, including assets whose licence you cannot prove from
|
||||
- path or URL, title, author;
|
||||
- source (URL, with version or commit);
|
||||
- SPDX licence, and the licence file in the repo;
|
||||
- SHA-256;
|
||||
- SHA-256, except for translation catalogs (see below);
|
||||
- how it is used.
|
||||
- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed.
|
||||
- **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand.
|
||||
- **`NOTICE`** keeps the licence notices that the licences require.
|
||||
- **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if:
|
||||
- an asset file in the repository has no entry;
|
||||
- an entry's hash does not match;
|
||||
- an entry's hash is missing or does not match (translation catalogs carry none);
|
||||
- a licence is not on the allowlist;
|
||||
- a licence file is missing;
|
||||
- an image, icon or font names Adobe as author or source.
|
||||
- an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone.
|
||||
- **`cargo xtask demo-pdf`** downloads its fonts only from `ATTRIBUTION.toml` entries, pinned by SHA-256. Afterwards it **fails if the PDF embeds any font not in its allowlist**, which catches the browser silently falling back to a system font.
|
||||
|
||||
### 1.4 Fonts live in craft-fonts
|
||||
|
||||
+35
-35
@@ -2,39 +2,39 @@
|
||||
|
||||
<!-- Generated from ATTRIBUTION.toml by `cargo xtask assets --write`. Do not edit by hand. -->
|
||||
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (288)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
@@ -285,17 +285,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
|
||||
## Compiled in through dependencies (5)
|
||||
|
||||
@@ -342,4 +342,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui
|
||||
|
||||
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1) |
|
||||
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size |
|
||||
+36
-67
@@ -2,17 +2,18 @@
|
||||
path = "crates/ui-egui/src/i18n/el.tsv"
|
||||
title = "Greek interface translation catalog"
|
||||
author = "Camthalion77 and contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
#
|
||||
# [[asset]] a file in this repository (committed or vendored); `sha256` must match the file.
|
||||
# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files
|
||||
# under the project licence and carry no `sha256`: git history records their provenance.
|
||||
# [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock).
|
||||
# [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`.
|
||||
# [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR);
|
||||
@@ -25,286 +26,260 @@ sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394"
|
||||
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
|
||||
title = "Simplified Chinese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
title = "Traditional Chinese (Taiwan) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
title = "Czech interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
title = "Brazilian Portuguese interface translation catalog"
|
||||
author = "André Cardoso (andrecards), PdfCraft contributors"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
title = "Spanish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
title = "French interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
title = "German interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lo.tsv"
|
||||
title = "Lao interface translation catalog"
|
||||
author = "@bounkirdni-2025"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
title = "Telugu interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
title = "Italian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
title = "Polish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
title = "Russian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
title = "Hungarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
title = "Bulgarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
title = "Kazakh interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
title = "Japanese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
title = "Ukrainian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
title = "Arabic interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ckb.tsv"
|
||||
title = "Central Kurdish (Sorani) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
title = "Turkish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
title = "Lithuanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hi.tsv"
|
||||
title = "Hindi interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/id.tsv"
|
||||
title = "Indonesian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ko.tsv"
|
||||
title = "Korean interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ro.tsv"
|
||||
title = "Romanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091"
|
||||
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fi.tsv"
|
||||
title = "Finnish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -3393,7 +3368,7 @@ source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/
|
||||
licence = "OFL-1.1"
|
||||
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
|
||||
kind = "font"
|
||||
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1)"
|
||||
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
|
||||
@@ -3421,23 +3396,21 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
|
||||
path = "crates/ui-egui/src/i18n/vi.tsv"
|
||||
title = "Vietnamese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
title = "Slovene interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f"
|
||||
|
||||
[[asset]]
|
||||
|
||||
@@ -3455,12 +3428,11 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
|
||||
path = "crates/ui-egui/src/i18n/nb.tsv"
|
||||
title = "Norwegian Bokmål interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/superscript.svg"
|
||||
@@ -3510,31 +3482,28 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
|
||||
path = "crates/ui-egui/src/i18n/sq.tsv"
|
||||
title = "Albanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/th.tsv"
|
||||
title = "Thai interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ca.tsv"
|
||||
title = "Catalan interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c"
|
||||
Generated
+7
-4
@@ -1243,9 +1243,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
|
||||
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
@@ -1291,9 +1291,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
@@ -4235,9 +4235,11 @@ dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4347,6 +4349,7 @@ dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"rc2",
|
||||
"ripemd",
|
||||
"rsa",
|
||||
|
||||
+6
-6
@@ -89,20 +89,20 @@ dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# `"*"` matches every non-workspace package: crates.io dependencies and the
|
||||
# vendored crates under `vendor/` (excluded from the workspace). Our own crates
|
||||
# keep full debug info. Dependencies keep only line tables, so backtraces still
|
||||
# show file and line, but debug info for the whole dependency graph stays out of
|
||||
# every test binary (each UI test binary had a 500-600 MB PDB on Windows, #454).
|
||||
# vendored crates under `vendor/` (excluded from the workspace). Dependencies
|
||||
# carry no debug info and our own crates only line tables: backtraces and
|
||||
# panics still show file and line, while test binaries and the target dir stay
|
||||
# small (each UI test binary had a 500-600 MB PDB on Windows, #454).
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = "line-tables-only"
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.low-disk]
|
||||
inherits = "dev"
|
||||
debug = "line-tables-only"
|
||||
incremental = false
|
||||
|
||||
[profile.release]
|
||||
|
||||
+52
-1
@@ -2,7 +2,13 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; 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)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
@@ -54,6 +60,8 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
|
||||
@@ -190,6 +198,24 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
|
||||
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
|
||||
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
|
||||
- **2026-10-23 (M11, optimizer resolution):** Image downsampling includes each page's `/UserUnit` scale, preserving images already below the physical resolution threshold, including shared nested forms.
|
||||
Synthetic measurement and optimization regressions cover direct and indirect units and page-local defaults.
|
||||
Estimates unchanged.
|
||||
- **2026-10-23 (M10, Word table widths):** Word export sizes each cell from the grid columns it spans, keeping unequal columns and cells after a merged cell consistent with the table grid.
|
||||
- **2026-10-23 (M10, table continuation text):** Office exporters retain text from table slots covered by another cell's span as paragraph text.
|
||||
Synthetic regressions check every input fragment survives exactly once in Word, HTML, RTF and PowerPoint, while ordinary superscripts stay in their table cells.
|
||||
- **2026-10-23 (M10, image export):** Soft-mask Decode intervals preserve partial opacity in Export all images and Save Image As.
|
||||
Synthetic regressions cover direct and indirect ranges, inverted/subrange alpha, packed masks, JPEG masks and unchanged image component Decode.
|
||||
- **2026-10-23 (M10, print resources):** Vector printing preserves original page artwork when existing XObjects use annotation resource names.
|
||||
The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
|
||||
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
|
||||
- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
|
||||
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
|
||||
|
||||
@@ -207,6 +233,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
|
||||
- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
|
||||
- **2026-10-22 (M6, calculation scripts):** The Calculate tab and a rename read simplified field notation only from a comment, where Acrobat writes it: a string that spells `BVCALC … EVCALC` is no longer taken for the notation, or rewritten by a rename. A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, so a rename no longer rewrites a name inside one, and a quote inside one no longer hides the `getField` calls after it. Both found in review of #3550. A `//` comment ends at `\r`, U+2028 or U+2029 as well as `\n`, so in a script with `\r` line endings the code after one is no longer hidden from a rename, or taken for the notation. Estimates unchanged.
|
||||
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
|
||||
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
|
||||
- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
|
||||
@@ -227,6 +254,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
|
||||
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
|
||||
@@ -245,6 +274,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
|
||||
- **2026-10-18 (M12, #2589):** Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged.
|
||||
- **2026-10-18 (M7, #2592):** Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged.
|
||||
- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged.
|
||||
- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
|
||||
- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged.
|
||||
@@ -272,6 +303,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
|
||||
- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
|
||||
- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
|
||||
- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged.
|
||||
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
@@ -298,6 +331,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged.
|
||||
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
|
||||
|
||||
- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image.
|
||||
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
|
||||
- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged.
|
||||
- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
|
||||
@@ -345,6 +379,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged.
|
||||
- **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending.
|
||||
- **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged.
|
||||
|
||||
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
|
||||
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
|
||||
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
|
||||
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
|
||||
@@ -637,6 +674,13 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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. |
|
||||
| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
|
||||
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
|
||||
@@ -681,8 +725,15 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
|
||||
|
||||
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
|
||||
|
||||
- 2026-10-18: M11 / #3227: mark target-version compatibility partial; document the unchecked newer-content limitation.
|
||||
|
||||
- **2026-10-18 (PR #3227 integration):** M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged.
|
||||
|
||||
- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker.
|
||||
|
||||
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
|
||||
|
||||
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
|
||||
|
||||
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
|
||||
@@ -84,7 +84,24 @@ help and feedback: https://discord.gg/artcraft"
|
||||
);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an
|
||||
// `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even
|
||||
// `--help` ended that way. Such a name cannot be passed on either, since the paths here are
|
||||
// `str`, so it is a usage error (photocraft#1108).
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
for arg in std::env::args_os().skip(1) {
|
||||
match arg.into_string() {
|
||||
Ok(a) => args.push(a),
|
||||
Err(bad) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument",
|
||||
bad.to_string_lossy()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `<command> --help` too; only right after the command, so a later `-h` (a value) still reaches it.
|
||||
let command = match args.get(1).map(String::as_str) {
|
||||
Some("--help" | "-h") => Some("--help"),
|
||||
@@ -169,7 +186,7 @@ fn version() -> Result<(), CliError> {
|
||||
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
|
||||
fn audit(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("audit", args, AUDIT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("audit: missing file")?;
|
||||
let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
|
||||
@@ -208,6 +225,28 @@ struct OptionSpec {
|
||||
takes_value: bool,
|
||||
}
|
||||
|
||||
/// The options whose value is a file or folder. Only these refuse a value that is another
|
||||
/// option: a file named `--password` is never what was meant, while a password can be anything
|
||||
/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title.
|
||||
/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered.
|
||||
const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"];
|
||||
|
||||
/// Whether a token can be the value of a path option. Another option cannot: `--out --password`
|
||||
/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944).
|
||||
fn can_be_path_value(name: &str, value: Option<&str>) -> bool {
|
||||
!PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// The complaint about a path option whose value is missing or is another option.
|
||||
fn needs_value(command: &str, name: &str, value: Option<&str>) -> String {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => {
|
||||
format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name")
|
||||
}
|
||||
_ => format!("{command}: {name} needs a value"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse unknown options and malformed confinement flags before any file is read.
|
||||
fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
|
||||
if specs.iter().any(|spec| spec.name == "--root") {
|
||||
@@ -230,9 +269,19 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
|
||||
let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
|
||||
return Err(format!("{command}: unknown option --{name}").into());
|
||||
};
|
||||
// Other missing or malformed values are left to the command, which reports them in its own
|
||||
// terms (`text` and `extract` explain what a page value must be).
|
||||
i += if spec.takes_value { 2 } else { 1 };
|
||||
// A file's name is not another option, which also closes the gap the walk left for
|
||||
// those: an unknown option in that value position was skipped along with it. Other
|
||||
// values, missing or malformed, are left to the command, which reports them in its
|
||||
// own terms (`text` and `extract` explain what a page value must be).
|
||||
i += if spec.takes_value {
|
||||
let value = args.get(i + 1).map(String::as_str);
|
||||
if !can_be_path_value(spec.name, value) {
|
||||
return Err(needs_value(command, spec.name, value).into());
|
||||
}
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -278,19 +327,24 @@ const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value:
|
||||
#[cfg(feature = "mcp")]
|
||||
const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
/// The arguments that are not options or their values. Which tokens are values is the spec's
|
||||
/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing
|
||||
/// file", the file having been read as `--edit`'s value.
|
||||
fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
continue;
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
match specs.iter().find(|spec| spec.name == a.as_str()) {
|
||||
Some(spec) if spec.takes_value => i += 2,
|
||||
// An option that takes no value, and an unknown one, which `validate_options` has
|
||||
// already refused by the time this runs.
|
||||
Some(_) => i += 1,
|
||||
None if a.starts_with("--") => i += 1,
|
||||
None => {
|
||||
out.push(a.as_str());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if a.starts_with("--") {
|
||||
skip = true;
|
||||
continue;
|
||||
}
|
||||
out.push(a.as_str());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -301,7 +355,7 @@ fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
|
||||
|
||||
fn info(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("info", args, INFO_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("info: missing file")?;
|
||||
let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
@@ -354,7 +408,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
|
||||
|
||||
fn text(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("text", args, TEXT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("text: missing file")?;
|
||||
let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?;
|
||||
let mut selected_page = None;
|
||||
// Validate every supplied value before reading, retaining the first valid selection.
|
||||
let mut options = args.iter();
|
||||
@@ -482,7 +536,13 @@ fn edit(args: &[String]) -> Result<(), CliError> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?;
|
||||
// `edit` parses its own options (they run in order), so `--out` is checked here in the
|
||||
// same way: it wrote a PDF to a file called `--full`.
|
||||
let value = args.get(i + 1).map(String::as_str);
|
||||
if !can_be_path_value(arg, value) {
|
||||
return Err(needs_value("edit", arg, value).into());
|
||||
}
|
||||
let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?;
|
||||
match arg {
|
||||
"--out" => out = Some(value),
|
||||
"--password" => password = Some(value),
|
||||
@@ -569,7 +629,7 @@ fn file_stem(path: &str) -> String {
|
||||
fn combine(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("combine", args, COMBINE_OPTIONS)?;
|
||||
let out = flag(args, "--out").ok_or("combine: missing --out")?;
|
||||
let inputs = positional(args);
|
||||
let inputs = positional(args, COMBINE_OPTIONS);
|
||||
if inputs.len() < 2 {
|
||||
return Err("combine: give at least two input files".into());
|
||||
}
|
||||
@@ -580,7 +640,7 @@ fn combine(args: &[String]) -> Result<(), CliError> {
|
||||
|
||||
fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("extract", args, EXTRACT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("extract: missing file")?;
|
||||
let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?;
|
||||
let out = flag(args, "--out").ok_or("extract: missing --out")?;
|
||||
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
@@ -592,7 +652,7 @@ fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
fn split(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("split", args, SPLIT_OPTIONS)?;
|
||||
use pdfcraft_engine::SplitBy;
|
||||
let path = *positional(args).first().ok_or("split: missing file")?;
|
||||
let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?;
|
||||
let by = match (flag(args, "--every"), flag(args, "--before")) {
|
||||
(Some(n), None) => {
|
||||
// A count the splitter cannot hold is refused here, before the file is opened,
|
||||
@@ -637,14 +697,14 @@ fn parse_dpi(value: &str) -> Result<f32, String> {
|
||||
|
||||
fn render(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("render", args, RENDER_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("render: missing file")?;
|
||||
let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?;
|
||||
let page: usize =
|
||||
flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
|
||||
let page_index = page - 1;
|
||||
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
|
||||
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
|
||||
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
|
||||
let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
|
||||
let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("png") => Some(ImageFormat::Png),
|
||||
Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }),
|
||||
Some("tif" | "tiff") => Some(ImageFormat::Tiff),
|
||||
@@ -688,6 +748,10 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
pdfcraft_render::RenderWarning::ContentTruncated => {
|
||||
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
|
||||
}
|
||||
pdfcraft_render::RenderWarning::ColourFallback => {
|
||||
let _ =
|
||||
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -696,7 +760,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
/// Child-process body for `check`: prints one JSON line.
|
||||
fn check_one(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("check-one: missing file")?;
|
||||
let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
|
||||
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
|
||||
let start = Instant::now();
|
||||
let bytes = read(path)?;
|
||||
@@ -779,7 +843,7 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
|
||||
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||
stack.extend(rd.flatten().map(|e| e.path()));
|
||||
}
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
@@ -802,7 +866,7 @@ fn check(args: &[String]) -> Result<(), CliError> {
|
||||
);
|
||||
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
|
||||
parse_dpi(&dpi)?;
|
||||
let files = collect(&positional(args));
|
||||
let files = collect(&positional(args, CHECK_OPTIONS));
|
||||
if files.is_empty() {
|
||||
return Err("check: no PDF files found".into());
|
||||
}
|
||||
@@ -985,9 +1049,9 @@ fn run(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
|
||||
let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
|
||||
let mut obj = serde_json::Map::new();
|
||||
for kv in positional(args).iter().skip(1) {
|
||||
for kv in positional(args, RUN_OPTIONS).iter().skip(1) {
|
||||
let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
|
||||
let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
|
||||
obj.insert(k.to_string(), value);
|
||||
@@ -1053,7 +1117,7 @@ fn ui(args: &[String]) -> Result<(), CliError> {
|
||||
// Refused rather than truncated, which would quietly reach some other port.
|
||||
let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
|
||||
let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
|
||||
let pos = positional(args);
|
||||
let pos = positional(args, UI_OPTIONS);
|
||||
let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
|
||||
let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
@@ -27,3 +27,89 @@ fn non_edit_commands_reject_unknown_options_before_io() {
|
||||
assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// photocraft#6944: an option took the next token whatever it was, so a line with a value missing
|
||||
/// in the middle of it did something else entirely — `extract in.pdf --pages 1 --out --password`
|
||||
/// wrote the PDF to a file called `--password` and exited 0. The unknown option hidden in a value
|
||||
/// position passed the check above too, since the walk skipped it along with the option.
|
||||
///
|
||||
/// Only the options whose value is a file or folder refuse one: a password or a title can be any
|
||||
/// string, `--page` included, which `text`'s own tests pin and this does not change.
|
||||
#[test]
|
||||
fn a_file_option_refuses_a_value_that_is_another_option() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-values-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
for (option, args) in [
|
||||
("--out", vec!["extract", "missing.pdf", "--pages", "1", "--out", "--password"]),
|
||||
("--out", vec!["combine", "a.pdf", "b.pdf", "--out", "--nonsense"]),
|
||||
("--out", vec!["render", "missing.pdf", "--out", "--dpi", "96"]),
|
||||
("--out-dir", vec!["split", "missing.pdf", "--every", "2", "--out-dir", "--password"]),
|
||||
("--json", vec!["check", "missing.pdf", "--json", "--dpi"]),
|
||||
("--control", vec!["ui", "--control", "--out", "state"]),
|
||||
// `edit` parses its own options, in the order given, and had the same gap.
|
||||
("--out", vec!["edit", "missing.pdf", "--out", "--full"]),
|
||||
] {
|
||||
let out = Command::new(BIN).current_dir(&dir).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
|
||||
assert!(stderr.contains(&format!("{option} needs a value")), "{args:?}: {stderr}");
|
||||
assert!(stderr.contains("another option"), "{args:?}: {stderr}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("read dir").count(), 0, "nothing was written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// And a file whose name really does start with `-` is still reachable, the way the shell reaches
|
||||
/// one and `--root` already documented: `./--name`. The error is then about the input file, which
|
||||
/// is as far as these lines get here.
|
||||
#[test]
|
||||
fn a_file_whose_name_starts_with_dashes_is_still_reachable() {
|
||||
// The error is about the file the command could not open, so the test asks which file it
|
||||
// names -- not how the OS words "not found", which differs on Windows (#3849 review).
|
||||
for (names, args) in [
|
||||
("missing.pdf", vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"]),
|
||||
("missing.pdf", vec!["edit", "missing.pdf", "--out", "./--dash.pdf"]),
|
||||
("./--dash.pdf", vec!["info", "./--dash.pdf"]),
|
||||
] {
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?}: no such file either way");
|
||||
assert!(stderr.contains(names), "{args:?}: the error is about {names}: {stderr}");
|
||||
// Not a usage error: the `./--dash.pdf` reached the command as a file name.
|
||||
assert!(!stderr.contains("needs a value") && !stderr.contains("unknown option"), "{args:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The token after an option that takes no value is not its value: `check-one --edit file.pdf`
|
||||
/// reported "check-one: missing file", the file having been read as `--edit`'s operand.
|
||||
#[test]
|
||||
fn a_flag_that_takes_no_value_does_not_swallow_the_file() {
|
||||
for args in [vec!["check-one", "--edit", "missing.pdf"], vec!["check-one", "missing.pdf", "--edit"]] {
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
// Which file the error is about, not how the OS says it is missing (#3849 review).
|
||||
assert!(stderr.contains("missing.pdf"), "{args:?}: the error is about the file: {stderr}");
|
||||
assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}");
|
||||
assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// photocraft#1108: an argument that is not UTF-8 panicked in `std::env::args` ("called
|
||||
/// `Result::unwrap()` on an `Err` value"), while collecting the arguments — so it panicked even
|
||||
/// for `--help`. Such a name cannot be used here, where paths are `str`, so it is a usage error.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
for args in [vec![OsStr::new("info"), OsStr::from_bytes(b"/tmp/caf\xe9.pdf")], vec![OsStr::new("--help"), OsStr::from_bytes(b"caf\xe9")]] {
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "an error, not a panic (101): {stderr}");
|
||||
assert!(stderr.contains("not valid UTF-8"), "{stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! PdfCraft desktop app.
|
||||
//!
|
||||
//! Usage: `pdfcraft [options] [files…]`
|
||||
//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]).
|
||||
//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI.
|
||||
//!
|
||||
//! View options (applied after the files open; also the seed of the UI control channel):
|
||||
@@ -56,6 +56,32 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/pdfcraft-10
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png");
|
||||
|
||||
/// What `-h` / `--help` prints (#4076): a hint, not the full option list (that is this file's
|
||||
/// doc comment and the README). Scripts want `pdfcraft-cli`, which never opens a window.
|
||||
const USAGE: &str = "\
|
||||
Usage: pdfcraft [options] [files…]
|
||||
|
||||
Opens the files in the PdfCraft desktop app.
|
||||
|
||||
Options:
|
||||
--new-window open a window of its own instead of passing the files to a running PdfCraft
|
||||
--create-images [images…] put the images in one PDF and ask for the page DPI
|
||||
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
|
||||
--page N, --zoom 150, --layout continuous, --theme dark, …
|
||||
view settings applied once the files are open
|
||||
-h, --help print this and exit
|
||||
--version print the version and exit
|
||||
|
||||
To inspect, convert or edit PDFs from a script, use pdfcraft-cli (pdfcraft-cli --help).";
|
||||
|
||||
/// Print `text` for `--help` and `--version`. `println!` would panic on a full or closed
|
||||
/// standard output; this exits normally instead.
|
||||
fn print_and_exit(text: &str) -> eframe::Result {
|
||||
use std::io::Write;
|
||||
let _ = writeln!(std::io::stdout().lock(), "{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app was called PrintCraft before; settings saved then are under this key.
|
||||
const LEGACY_STORAGE_KEY: &str = "printcraft";
|
||||
|
||||
@@ -196,10 +222,9 @@ fn main() -> eframe::Result {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--version" => {
|
||||
println!("pdfcraft {}", pdfcraft_engine::build_info::version());
|
||||
return Ok(());
|
||||
}
|
||||
"--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())),
|
||||
// Before any window or log file, so a scripted call returns (#4076).
|
||||
"-h" | "--help" => return print_and_exit(USAGE),
|
||||
"--control" => control_file = args.next(),
|
||||
"--create-images" => create_images = true,
|
||||
"--new-window" => new_window = true,
|
||||
@@ -1414,4 +1439,26 @@ mod tests {
|
||||
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
|
||||
assert_eq!(class, Some(super::APP_ID));
|
||||
}
|
||||
|
||||
// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
|
||||
// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
|
||||
// path and the hook must write to standard error without it.
|
||||
#[test]
|
||||
fn start_up_and_the_panic_hook_never_use_eprintln() {
|
||||
let source = include_str!("main.rs");
|
||||
// Everything before the top-level test module is product code.
|
||||
let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source);
|
||||
assert!(product.len() < source.len(), "found the end of the product code");
|
||||
let offenders: Vec<_> = product
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!"))
|
||||
.map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim()))
|
||||
.collect();
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! `--help`, `-h` and `--version` print and exit without opening a window (#4076), so a script that
|
||||
//! calls them returns.
|
||||
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft");
|
||||
|
||||
/// Run the app with `args`, failing if it is still running after 60 s (it opened its window).
|
||||
fn run(args: &[&str], stdout: Stdio) -> Output {
|
||||
let mut child = Command::new(BIN).args(args).stdin(Stdio::null()).stdout(stdout).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("pdfcraft {args:?} did not exit: it started the app instead");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_prints_usage_and_exits() {
|
||||
for flag in ["--help", "-h"] {
|
||||
let out = run(&[flag], Stdio::piped());
|
||||
assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.starts_with("Usage: pdfcraft [options] [files…]"), "{flag}: {stdout}");
|
||||
assert!(stdout.contains("pdfcraft-cli"), "{flag}: points scripts to the CLI: {stdout}");
|
||||
}
|
||||
// Wherever it is on the command line, and with files named.
|
||||
let out = run(&["missing.pdf", "--help"], Stdio::piped());
|
||||
assert!(out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stdout).starts_with("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_prints_the_version_and_exits() {
|
||||
let out = run(&["--version"], Stdio::piped());
|
||||
assert!(out.status.success(), "{:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
assert!(String::from_utf8_lossy(&out.stdout).starts_with("pdfcraft "), "{}", String::from_utf8_lossy(&out.stdout));
|
||||
}
|
||||
|
||||
/// A standard output that can't be written (here a full device) ends the run normally: `println!`
|
||||
/// would panic and exit 101.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn help_and_version_survive_an_unwritable_stdout() {
|
||||
for flag in ["--help", "--version"] {
|
||||
let full = std::fs::OpenOptions::new().write(true).open("/dev/full").unwrap();
|
||||
let out = run(&[flag], Stdio::from(full));
|
||||
assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
}
|
||||
+39
-39
@@ -14,27 +14,27 @@
|
||||
(highlight, sticky note, stamp, ...), so annotation geometry follows the layout.
|
||||
-->
|
||||
<style>
|
||||
@font-face { font-family: "PC Mono"; src: url("{{ASSETS}}/fonts/JetBrainsMono-Regular.ttf"); }
|
||||
@font-face { font-family: "PC Inter"; src: url("{{FONTS}}/Inter[opsz,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-family: "PC Inter"; src: url("{{FONTS}}/Inter-Italic[opsz,wght].ttf"); font-weight: 100 900; font-style: italic; }
|
||||
@font-face { font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond[wght].ttf"); font-weight: 400 800; }
|
||||
@font-face { font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond-Italic[wght].ttf"); font-weight: 400 800; font-style: italic; }
|
||||
@font-face { font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay[wght].ttf"); font-weight: 400 900; }
|
||||
@font-face { font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay-Italic[wght].ttf"); font-weight: 400 900; font-style: italic; }
|
||||
@font-face { font-family: "PC Vibes"; src: url("{{FONTS}}/GreatVibes-Regular.ttf"); }
|
||||
@font-face { font-family: "PC Math"; src: url("{{FONTS}}/STIXTwoMath-Regular.ttf"); }
|
||||
@font-face { font-family: "PC Amiri"; src: url("{{FONTS}}/Amiri-Regular.ttf"); }
|
||||
@font-face { font-family: "PC Hebrew"; src: url("{{FONTS}}/NotoSansHebrew[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-family: "PC Devanagari"; src: url("{{FONTS}}/NotoSansDevanagari[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-family: "PC Thai"; src: url("{{FONTS}}/NotoSansThai[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-family: "PC Armenian"; src: url("{{FONTS}}/NotoSansArmenian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-family: "PC Georgian"; src: url("{{FONTS}}/NotoSansGeorgian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-family: "PC Tamil"; src: url("{{FONTS}}/NotoSansTamil[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-family: "PC Charis"; src: url("{{FONTS}}/CharisSIL-Regular.ttf"); }
|
||||
@font-face { font-family: "PC XiaoWei"; src: url("{{FONTS}}/ZCOOLXiaoWei-Regular.ttf"); }
|
||||
@font-face { font-family: "PC Mincho"; src: url("{{FONTS}}/ShipporiMincho-Regular.ttf"); }
|
||||
@font-face { font-family: "PC Gowun"; src: url("{{FONTS}}/GowunBatang-Regular.ttf"); }
|
||||
@font-face { font-family: "PC Emoji"; src: url("{{FONTS}}/NotoColorEmoji-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Mono"; src: url("{{ASSETS}}/fonts/JetBrainsMono-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Inter"; src: url("{{FONTS}}/Inter[opsz,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Inter"; src: url("{{FONTS}}/Inter-Italic[opsz,wght].ttf"); font-weight: 100 900; font-style: italic; }
|
||||
@font-face { font-display: block; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond[wght].ttf"); font-weight: 400 800; }
|
||||
@font-face { font-display: block; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond-Italic[wght].ttf"); font-weight: 400 800; font-style: italic; }
|
||||
@font-face { font-display: block; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay[wght].ttf"); font-weight: 400 900; }
|
||||
@font-face { font-display: block; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay-Italic[wght].ttf"); font-weight: 400 900; font-style: italic; }
|
||||
@font-face { font-display: block; font-family: "PC Vibes"; src: url("{{FONTS}}/GreatVibes-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Math"; src: url("{{FONTS}}/STIXTwoMath-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Amiri"; src: url("{{FONTS}}/Amiri-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Hebrew"; src: url("{{FONTS}}/NotoSansHebrew[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Devanagari"; src: url("{{FONTS}}/NotoSansDevanagari[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Thai"; src: url("{{FONTS}}/NotoSansThai[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Armenian"; src: url("{{FONTS}}/NotoSansArmenian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Georgian"; src: url("{{FONTS}}/NotoSansGeorgian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Tamil"; src: url("{{FONTS}}/NotoSansTamil[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Charis"; src: url("{{FONTS}}/CharisSIL-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC XiaoWei"; src: url("{{FONTS}}/ZCOOLXiaoWei-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Mincho"; src: url("{{FONTS}}/ShipporiMincho-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Gowun"; src: url("{{FONTS}}/GowunBatang-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Emoji"; src: url("{{FONTS}}/NotoColorEmoji-Regular.ttf"); }
|
||||
|
||||
@page { size: Letter; margin: 0; }
|
||||
|
||||
@@ -598,11 +598,11 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
<p class="dek">Nine small plates, each exercising one piece of the PDF imaging model.</p>
|
||||
<div class="gfx">
|
||||
<figure>
|
||||
<svg viewBox="0 0 160 120"><defs><linearGradient id="lg1" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2a6fdb"/><stop offset="0.5" stop-color="#1a9e8f"/><stop offset="1" stop-color="#e9a100"/></linearGradient></defs><rect width="160" height="120" rx="6" fill="url(#lg1)"/></svg>
|
||||
<svg role="img" aria-label="Axial shading: three-stop linear gradient" viewBox="0 0 160 120"><defs><linearGradient id="lg1" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2a6fdb"/><stop offset="0.5" stop-color="#1a9e8f"/><stop offset="1" stop-color="#e9a100"/></linearGradient></defs><rect width="160" height="120" rx="6" fill="url(#lg1)"/></svg>
|
||||
<figcaption class="caption"><b>Axial shading</b> · three-stop linear gradient</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="0 0 160 120"><defs><radialGradient id="rg1" cx="0.35" cy="0.35" r="0.75"><stop offset="0" stop-color="#fff5d6"/><stop offset="0.35" stop-color="#e9a100"/><stop offset="0.75" stop-color="#e4572e"/><stop offset="1" stop-color="#16213b"/></radialGradient></defs><rect width="160" height="120" rx="6" fill="#16213b"/><circle cx="80" cy="60" r="52" fill="url(#rg1)"/></svg>
|
||||
<svg role="img" aria-label="Radial shading: off-centre focal point" viewBox="0 0 160 120"><defs><radialGradient id="rg1" cx="0.35" cy="0.35" r="0.75"><stop offset="0" stop-color="#fff5d6"/><stop offset="0.35" stop-color="#e9a100"/><stop offset="0.75" stop-color="#e4572e"/><stop offset="1" stop-color="#16213b"/></radialGradient></defs><rect width="160" height="120" rx="6" fill="#16213b"/><circle cx="80" cy="60" r="52" fill="url(#rg1)"/></svg>
|
||||
<figcaption class="caption"><b>Radial shading</b> · off-centre focal point</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
@@ -614,31 +614,31 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
<figcaption class="caption"><b>Screen</b> · additive light, /BM /Screen</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="0 0 160 120"><defs><clipPath id="cp1"><text x="80" y="88" text-anchor="middle" font-family="PC Playfair, serif" font-size="70" font-weight="bold">Clip</text></clipPath><linearGradient id="lg2" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#e4572e"/><stop offset="1" stop-color="#b83b5e"/></linearGradient></defs><rect width="160" height="120" rx="6" fill="#f1ece2"/><g clip-path="url(#cp1)"><rect width="160" height="120" fill="url(#lg2)"/><g stroke="#f2c14e" stroke-width="3">
|
||||
<svg role="img" aria-label="Clipping path: glyph outlines as a clip" viewBox="0 0 160 120"><defs><clipPath id="cp1"><text x="80" y="88" text-anchor="middle" font-family="PC Playfair, serif" font-size="70" font-weight="bold">Clip</text></clipPath><linearGradient id="lg2" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#e4572e"/><stop offset="1" stop-color="#b83b5e"/></linearGradient></defs><rect width="160" height="120" rx="6" fill="#f1ece2"/><g clip-path="url(#cp1)"><rect width="160" height="120" fill="url(#lg2)"/><g stroke="#f2c14e" stroke-width="3">
|
||||
<line x1="0" y1="10" x2="160" y2="130"/><line x1="0" y1="40" x2="160" y2="160"/><line x1="0" y1="-20" x2="160" y2="100"/><line x1="0" y1="-50" x2="160" y2="70"/></g></g></svg>
|
||||
<figcaption class="caption"><b>Clipping path</b> · glyph outlines as a clip</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="0 0 160 120"><defs><linearGradient id="fade" x1="0" x2="1"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><mask id="m1"><rect width="160" height="120" fill="url(#fade)"/></mask></defs><rect width="160" height="120" rx="6" fill="#f1ece2"/><g mask="url(#m1)"><rect x="12" y="14" width="136" height="92" rx="8" fill="#2a6fdb"/><circle cx="60" cy="60" r="26" fill="#f2c14e"/><circle cx="104" cy="60" r="26" fill="#e4572e"/></g></svg>
|
||||
<svg role="img" aria-label="Soft mask: luminosity gradient fades artwork" viewBox="0 0 160 120"><defs><linearGradient id="fade" x1="0" x2="1"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><mask id="m1"><rect width="160" height="120" fill="url(#fade)"/></mask></defs><rect width="160" height="120" rx="6" fill="#f1ece2"/><g mask="url(#m1)"><rect x="12" y="14" width="136" height="92" rx="8" fill="#2a6fdb"/><circle cx="60" cy="60" r="26" fill="#f2c14e"/><circle cx="104" cy="60" r="26" fill="#e4572e"/></g></svg>
|
||||
<figcaption class="caption"><b>Soft mask</b> · luminosity gradient fades artwork</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="0 0 160 120"><rect width="160" height="120" rx="6" fill="#fffdf8" stroke="#e2dccf"/><g fill="none" stroke="#16213b" stroke-width="2.5"><path d="M16 26 H144" stroke-dasharray="12 6"/><path d="M16 46 H144" stroke-dasharray="2 6" stroke-linecap="round"/><path d="M16 66 H144" stroke-dasharray="18 5 3 5" stroke="#e4572e"/><path d="M16 96 Q 50 70 80 96 T 144 96" stroke="#1a9e8f" stroke-width="5" stroke-linejoin="round" stroke-dasharray="1 9" stroke-linecap="round"/></g></svg>
|
||||
<svg role="img" aria-label="Dash patterns: caps, joins and phase" viewBox="0 0 160 120"><rect width="160" height="120" rx="6" fill="#fffdf8" stroke="#e2dccf"/><g fill="none" stroke="#16213b" stroke-width="2.5"><path d="M16 26 H144" stroke-dasharray="12 6"/><path d="M16 46 H144" stroke-dasharray="2 6" stroke-linecap="round"/><path d="M16 66 H144" stroke-dasharray="18 5 3 5" stroke="#e4572e"/><path d="M16 96 Q 50 70 80 96 T 144 96" stroke="#1a9e8f" stroke-width="5" stroke-linejoin="round" stroke-dasharray="1 9" stroke-linecap="round"/></g></svg>
|
||||
<figcaption class="caption"><b>Dash patterns</b> · caps, joins and phase</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="0 0 160 120"><defs><pattern id="checks" width="16" height="16" patternUnits="userSpaceOnUse"><rect width="16" height="16" fill="#f1ece2"/><rect width="8" height="8" fill="#16213b"/><rect x="8" y="8" width="8" height="8" fill="#16213b"/></pattern><pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="8" height="8" fill="#fbeccc"/><line x1="0" y1="0" x2="0" y2="8" stroke="#e9a100" stroke-width="3"/></pattern></defs><rect width="160" height="120" rx="6" fill="url(#checks)"/><circle cx="80" cy="60" r="40" fill="url(#hatch)" stroke="#16213b" stroke-width="2"/></svg>
|
||||
<svg role="img" aria-label="Tiling patterns: checks and rotated hatch" viewBox="0 0 160 120"><defs><pattern id="checks" width="16" height="16" patternUnits="userSpaceOnUse"><rect width="16" height="16" fill="#f1ece2"/><rect width="8" height="8" fill="#16213b"/><rect x="8" y="8" width="8" height="8" fill="#16213b"/></pattern><pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="8" height="8" fill="#fbeccc"/><line x1="0" y1="0" x2="0" y2="8" stroke="#e9a100" stroke-width="3"/></pattern></defs><rect width="160" height="120" rx="6" fill="url(#checks)"/><circle cx="80" cy="60" r="40" fill="url(#hatch)" stroke="#16213b" stroke-width="2"/></svg>
|
||||
<figcaption class="caption"><b>Tiling patterns</b> · checks and rotated hatch</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="0 0 160 120"><rect width="160" height="120" rx="6" fill="#16213b"/><g transform="translate(80 60)"><g fill="none" stroke-width="1.2">
|
||||
<svg role="img" aria-label="Fill rules: even-odd pentagram at 45% opacity" viewBox="0 0 160 120"><rect width="160" height="120" rx="6" fill="#16213b"/><g transform="translate(80 60)"><g fill="none" stroke-width="1.2">
|
||||
<path id="star" d="M0 -46 L13 -14 L46 -14 L19 6 L29 40 L0 20 L-29 40 L-19 6 L-46 -14 L-13 -14 Z" stroke="#f2c14e" fill="#f2c14e" fill-opacity="0.25" fill-rule="evenodd"/>
|
||||
<path d="M0 -46 L27 37 L-44 -14 L44 -14 L-27 37 Z" stroke="#e4572e" fill="#e4572e" fill-opacity="0.45" fill-rule="evenodd"/></g></g></svg>
|
||||
<figcaption class="caption"><b>Fill rules</b> · even-odd pentagram, 45% opacity</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="rule"></div>
|
||||
<svg viewBox="0 0 520 110" style="width:100%;height:auto;display:block">
|
||||
<svg role="img" aria-label="Blend spectrum: multiplied gradient bands" viewBox="0 0 520 110" style="width:100%;height:auto;display:block">
|
||||
<defs><linearGradient id="spec" x1="0" x2="1"><stop offset="0" stop-color="#16213b"/><stop offset="0.25" stop-color="#2a6fdb"/><stop offset="0.5" stop-color="#1a9e8f"/><stop offset="0.75" stop-color="#e9a100"/><stop offset="1" stop-color="#e4572e"/></linearGradient></defs>
|
||||
<g style="mix-blend-mode:multiply">
|
||||
<path d="M0 80 C 80 10, 160 110, 260 50 S 440 20, 520 70 L520 110 L0 110Z" fill="url(#spec)" opacity="0.85"/>
|
||||
@@ -789,16 +789,16 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
</div>
|
||||
<div class="canvas">
|
||||
<span class="lab" style="left:10pt;top:8pt">Review canvas</span>
|
||||
<a class="box" style="left:12pt;top:26pt;width:152pt;height:40pt" href="https://mark.pdfcraft.invalid/freetext/1">.</a>
|
||||
<a class="box" style="left:196pt;top:22pt;width:20pt;height:20pt" href="https://mark.pdfcraft.invalid/note/1">.</a>
|
||||
<a class="box" style="left:196pt;top:60pt;width:18pt;height:22pt" href="https://mark.pdfcraft.invalid/attach/1">.</a>
|
||||
<a class="box" style="left:16pt;top:88pt;width:90pt;height:60pt" href="https://mark.pdfcraft.invalid/square/1">.</a>
|
||||
<a class="box" style="left:120pt;top:92pt;width:90pt;height:60pt" href="https://mark.pdfcraft.invalid/circle/1">.</a>
|
||||
<a class="box" style="left:16pt;top:170pt;width:94pt;height:80pt" href="https://mark.pdfcraft.invalid/polygon/1">.</a>
|
||||
<a class="box" style="left:120pt;top:170pt;width:96pt;height:80pt" href="https://mark.pdfcraft.invalid/polyline/1">.</a>
|
||||
<a class="box" style="left:16pt;top:268pt;width:196pt;height:40pt" href="https://mark.pdfcraft.invalid/line/1">.</a>
|
||||
<a class="box" style="left:16pt;top:322pt;width:196pt;height:50pt" href="https://mark.pdfcraft.invalid/ink/1">.</a>
|
||||
<a class="box" style="left:30pt;top:388pt;width:170pt;height:56pt" href="https://mark.pdfcraft.invalid/stamp/1">.</a>
|
||||
<a class="box" style="left:12pt;top:26pt;width:152pt;height:40pt" href="https://mark.pdfcraft.invalid/freetext/1">freetext</a>
|
||||
<a class="box" style="left:196pt;top:22pt;width:20pt;height:20pt" href="https://mark.pdfcraft.invalid/note/1">note</a>
|
||||
<a class="box" style="left:196pt;top:60pt;width:18pt;height:22pt" href="https://mark.pdfcraft.invalid/attach/1">attach</a>
|
||||
<a class="box" style="left:16pt;top:88pt;width:90pt;height:60pt" href="https://mark.pdfcraft.invalid/square/1">square</a>
|
||||
<a class="box" style="left:120pt;top:92pt;width:90pt;height:60pt" href="https://mark.pdfcraft.invalid/circle/1">circle</a>
|
||||
<a class="box" style="left:16pt;top:170pt;width:94pt;height:80pt" href="https://mark.pdfcraft.invalid/polygon/1">polygon</a>
|
||||
<a class="box" style="left:120pt;top:170pt;width:96pt;height:80pt" href="https://mark.pdfcraft.invalid/polyline/1">polyline</a>
|
||||
<a class="box" style="left:16pt;top:268pt;width:196pt;height:40pt" href="https://mark.pdfcraft.invalid/line/1">line</a>
|
||||
<a class="box" style="left:16pt;top:322pt;width:196pt;height:50pt" href="https://mark.pdfcraft.invalid/ink/1">ink</a>
|
||||
<a class="box" style="left:30pt;top:388pt;width:170pt;height:56pt" href="https://mark.pdfcraft.invalid/stamp/1">stamp</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folio"><span>Review & Markup</span><b>9</b></div>
|
||||
|
||||
@@ -173,3 +173,6 @@ sync_github_name = true
|
||||
|
||||
[people.amoisttowe1]
|
||||
real_name = "Nick Nyssen"
|
||||
|
||||
[people.agent-reed]
|
||||
sync_github_name = true
|
||||
+10
-11
@@ -53,6 +53,15 @@ struct Walk<'a> {
|
||||
budget: DecodeBudget,
|
||||
}
|
||||
|
||||
fn bdc_marks(doc: &Document, op: &pdfcraft_content::Op, props: &Dict) -> bool {
|
||||
op.name(0) == Some(b"Artifact")
|
||||
|| match op.operands.get(1) {
|
||||
Some(Object::Dict(d)) => d.contains(b"MCID"),
|
||||
Some(Object::Name(n)) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
fn sub(&self, res: &Dict, key: &[u8]) -> Dict {
|
||||
res.get(key).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default()
|
||||
@@ -68,17 +77,7 @@ impl Walk<'_> {
|
||||
let inside = covered || stack.last().copied().unwrap_or(false);
|
||||
match op.op.as_slice() {
|
||||
b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")),
|
||||
b"BDC" => {
|
||||
let marks = op.name(0) == Some(b"Artifact")
|
||||
|| match op.operands.get(1) {
|
||||
Some(Object::Dict(d)) => d.contains(b"MCID"),
|
||||
Some(Object::Name(n)) => {
|
||||
props.get(n).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
stack.push(inside || marks);
|
||||
}
|
||||
b"BDC" => stack.push(inside || bdc_marks(self.doc, &op, &props)),
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
+138
-5
@@ -1,6 +1,6 @@
|
||||
//! Properties of reachable indirect structure elements.
|
||||
|
||||
use pdfcraft_cos::{CosError, Document, ObjRef, Object, PdfString};
|
||||
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, PdfString};
|
||||
|
||||
use crate::alt;
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct TagInfo {
|
||||
pub actual_text: Option<String>,
|
||||
pub alt: Option<String>,
|
||||
pub language: Option<String>,
|
||||
/// A table's `Summary` attribute.
|
||||
pub summary: Option<String>,
|
||||
/// A header cell's `Scope` attribute (`Row`, `Column` or `Both`).
|
||||
pub scope: Option<String>,
|
||||
/// Zero-based page when `/Pg` is known.
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
@@ -36,6 +40,10 @@ pub enum TagProperty {
|
||||
ActualText,
|
||||
AlternateText,
|
||||
Language,
|
||||
/// A table's `Summary`: a Table attribute (an attribute object with `/O /Table` in `/A`).
|
||||
Summary,
|
||||
/// A header cell's `Scope` (a Table attribute): `Row`, `Column` or `Both`.
|
||||
Scope,
|
||||
}
|
||||
|
||||
/// Where to place a structure tag relative to one of its direct siblings.
|
||||
@@ -53,6 +61,8 @@ impl TagProperty {
|
||||
"actual_text" => Some(Self::ActualText),
|
||||
"alternate_text" => Some(Self::AlternateText),
|
||||
"language" => Some(Self::Language),
|
||||
"summary" => Some(Self::Summary),
|
||||
"scope" => Some(Self::Scope),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -64,6 +74,17 @@ impl TagProperty {
|
||||
Self::ActualText => b"ActualText",
|
||||
Self::AlternateText => b"Alt",
|
||||
Self::Language => b"Lang",
|
||||
Self::Summary => b"Summary",
|
||||
Self::Scope => b"Scope",
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard structure type a Table attribute belongs to; `None` for the tag's own entries.
|
||||
fn table_attribute_of(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Summary => Some("Table"),
|
||||
Self::Scope => Some("TH"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +103,10 @@ pub enum TagError {
|
||||
AmbiguousChildren,
|
||||
#[error("the tag is already at the requested position")]
|
||||
AlreadyThere,
|
||||
#[error("{0} applies only to {1} tags")]
|
||||
WrongTagType(&'static str, &'static str),
|
||||
#[error("a scope must be Row, Column or Both")]
|
||||
InvalidScope,
|
||||
#[error(transparent)]
|
||||
Cos(#[from] CosError),
|
||||
}
|
||||
@@ -142,6 +167,8 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
|
||||
actual_text: text(doc, &e.dict, b"ActualText"),
|
||||
alt: text(doc, &e.dict, b"Alt"),
|
||||
language: text(doc, &e.dict, b"Lang"),
|
||||
summary: table_attribute(doc, &e.dict, b"Summary").and_then(|o| o.as_string().map(PdfString::to_text)),
|
||||
scope: table_attribute(doc, &e.dict, b"Scope").and_then(|o| o.as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
|
||||
page: e.page.and_then(|p| page_index.get(&p).copied()),
|
||||
})
|
||||
})
|
||||
@@ -200,11 +227,97 @@ fn direct_marks(doc: &Document, item: &Object, page: Option<ObjRef>, out: &mut V
|
||||
}
|
||||
}
|
||||
|
||||
/// Change one property of a reachable tag; `None` removes a text property.
|
||||
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
|
||||
if !alt::elements(doc).iter().any(|e| e.object == object) {
|
||||
return Err(TagError::NotATag(object));
|
||||
/// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally
|
||||
/// followed by its revision number), as written: each entry and its revision number.
|
||||
fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>, bool) {
|
||||
let Some(a) = d.get(b"A") else { return (Vec::new(), false) };
|
||||
// An indirect array is read as its items.
|
||||
let resolved = doc.resolve(a);
|
||||
let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) };
|
||||
let mut out: Vec<(Object, Option<Object>)> = Vec::new();
|
||||
for item in items {
|
||||
match (item, out.last_mut()) {
|
||||
(Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
|
||||
_ => out.push((item.clone(), None)),
|
||||
}
|
||||
}
|
||||
(out, true)
|
||||
}
|
||||
|
||||
/// Whether attribute object `o` (resolved) is a Table attribute object.
|
||||
fn is_table_attributes(o: &Object) -> bool {
|
||||
o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table"))
|
||||
}
|
||||
|
||||
/// The value of `key` in the first Table attribute object of tag `d` that has it.
|
||||
fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option<Object> {
|
||||
attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned())
|
||||
}
|
||||
|
||||
/// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first
|
||||
/// one, else a new one), or with `None` remove it from every Table attribute object that holds it.
|
||||
/// An edited object is written into the tag's own `/A`, so an attribute object other tags share
|
||||
/// keeps its value for them; one left with nothing but its owner goes, and so does an `/A` left
|
||||
/// empty.
|
||||
fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Option<Object>) -> Result<(), TagError> {
|
||||
let dict = doc.get(object).as_dict().cloned().ok_or(TagError::NotATag(object))?;
|
||||
let (entries, array) = attribute_entries(doc, &dict);
|
||||
// Each entry with its Table attributes (resolved), when it is a Table attribute object.
|
||||
let mut entries: Vec<(Object, Option<Object>, Option<Dict>)> = entries
|
||||
.into_iter()
|
||||
.map(|(o, r)| {
|
||||
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned());
|
||||
(o, r, table)
|
||||
})
|
||||
.collect();
|
||||
let holding = |e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().is_some_and(|d| d.contains(key));
|
||||
match value {
|
||||
Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) {
|
||||
Some(i) => {
|
||||
if let Some((o, _, Some(attributes))) = entries.get_mut(i) {
|
||||
attributes.set(key.to_vec(), v);
|
||||
*o = Object::Dict(attributes.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut attributes = Dict::new();
|
||||
attributes.set(b"O".to_vec(), Object::name("Table"));
|
||||
attributes.set(key.to_vec(), v);
|
||||
entries.push((Object::Dict(attributes), None, None));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
if !entries.iter().any(holding) {
|
||||
// Nothing to clear.
|
||||
return Ok(());
|
||||
}
|
||||
for (o, _, table) in &mut entries {
|
||||
if let Some(attributes) = table.as_mut().filter(|a| a.contains(key)) {
|
||||
attributes.remove(key);
|
||||
*o = Object::Dict(attributes.clone());
|
||||
}
|
||||
}
|
||||
entries.retain(|(_, _, table)| !table.as_ref().is_some_and(|a| a.iter().all(|(k, _)| k.as_slice() == b"O")));
|
||||
}
|
||||
}
|
||||
let entries: Vec<(Object, Option<Object>)> = entries.into_iter().map(|(o, r, _)| (o, r)).collect();
|
||||
doc.update_dict(object, |d| match entries.as_slice() {
|
||||
[] => {
|
||||
d.remove(b"A");
|
||||
}
|
||||
[(only, None)] if !array => d.set(b"A".to_vec(), only.clone()),
|
||||
_ => d.set(b"A".to_vec(), Object::Array(entries.into_iter().flat_map(|(o, r)| std::iter::once(o).chain(r)).collect())),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change one property of a reachable tag; `None` removes a text property. A table's summary and
|
||||
/// a header cell's scope are Table attributes ([`TagProperty::Summary`], [`TagProperty::Scope`]),
|
||||
/// set only on tags of that type after the role map.
|
||||
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
|
||||
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else {
|
||||
return Err(TagError::NotATag(object));
|
||||
};
|
||||
// ActualText may intentionally contain leading or trailing whitespace; preserve it.
|
||||
let value = value.filter(|s| !s.is_empty());
|
||||
if value.is_some_and(|s| s.len() > 65_536) {
|
||||
@@ -215,6 +328,26 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
|
||||
{
|
||||
return Err(TagError::InvalidType);
|
||||
}
|
||||
if let Some(owner) = property.table_attribute_of() {
|
||||
let role_map =
|
||||
alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default();
|
||||
if ty != owner.as_bytes() {
|
||||
let name = if property == TagProperty::Summary { "summary" } else { "scope" };
|
||||
return Err(TagError::WrongTagType(name, owner));
|
||||
}
|
||||
let value = match value {
|
||||
Some(s) if property == TagProperty::Scope => {
|
||||
if !matches!(s, "Row" | "Column" | "Both") {
|
||||
return Err(TagError::InvalidScope);
|
||||
}
|
||||
Some(Object::name(s))
|
||||
}
|
||||
Some(s) => Some(Object::String(PdfString::text(s))),
|
||||
None => None,
|
||||
};
|
||||
return set_table_attribute(doc, object, property.key(), value);
|
||||
}
|
||||
doc.update_dict(object, |d| match value {
|
||||
Some(s) if property == TagProperty::Type => d.set(property.key().to_vec(), Object::name(s)),
|
||||
Some(s) => d.set(property.key().to_vec(), Object::String(PdfString::text(s))),
|
||||
|
||||
@@ -367,6 +367,103 @@ fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() {
|
||||
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
|
||||
}
|
||||
|
||||
/// A table (object 4) with a header cell (6) and a data cell (7); `th` is the header cell's
|
||||
/// dictionary and `extra` more objects from 8 on, of which the Document element (3) lists `kids`
|
||||
/// after the table. The role map maps /MyTable to /Table.
|
||||
fn table_doc(th: &str, extra: &[&str], kids: &str) -> Document {
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(),
|
||||
"<< /Type /StructTreeRoot /K 3 0 R /RoleMap << /MyTable /Table >> >>".to_string(),
|
||||
format!("<< /S /Document /K [4 0 R {kids}] >>"),
|
||||
"<< /S /Table /K [5 0 R] >>".to_string(),
|
||||
"<< /S /TR /K [6 0 R 7 0 R] >>".to_string(),
|
||||
th.to_string(),
|
||||
"<< /S /TD >>".to_string(),
|
||||
];
|
||||
objs.extend(extra.iter().map(ToString::to_string));
|
||||
pdf(&objs, "")
|
||||
}
|
||||
|
||||
/// The attribute objects of tag `r`'s /A entry as written (one object or an array).
|
||||
fn attributes(doc: &Document, r: pdfcraft_cos::ObjRef) -> Option<pdfcraft_cos::Object> {
|
||||
doc.get(r).as_dict()?.get(b"A").cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_summary_is_a_table_attribute() {
|
||||
let mut doc = table_doc("<< /S /TH >>", &["<< /S /MyTable >>"], "8 0 R");
|
||||
let (table, th) = (pdfcraft_cos::ObjRef::new(4, 0), pdfcraft_cos::ObjRef::new(6, 0));
|
||||
set_tag_property(&mut doc, table, TagProperty::Summary, Some("Opening hours by day")).unwrap();
|
||||
let a = attributes(&doc, table).unwrap();
|
||||
let a = a.as_dict().unwrap();
|
||||
assert_eq!(a.name(b"O"), Some(&b"Table"[..]));
|
||||
assert_eq!(
|
||||
a.get(b"Summary").and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(),
|
||||
Some("Opening hours by day")
|
||||
);
|
||||
assert_eq!(tags(&doc).iter().find(|t| t.object == table).unwrap().summary.as_deref(), Some("Opening hours by day"));
|
||||
// Only a table takes one (after the role map: /MyTable is a table).
|
||||
assert!(matches!(set_tag_property(&mut doc, th, TagProperty::Summary, Some("No")), Err(TagError::WrongTagType(..))));
|
||||
set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), TagProperty::Summary, Some("Mapped")).unwrap();
|
||||
// Cleared, the attribute object has nothing left and goes.
|
||||
set_tag_property(&mut doc, table, TagProperty::Summary, None).unwrap();
|
||||
assert_eq!(attributes(&doc, table), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_cell_scope_keeps_its_other_attributes() {
|
||||
// The cell's /A holds a layout attribute object, then a table one with a revision number.
|
||||
let mut doc = table_doc("<< /S /TH /A [ << /O /Layout /Placement /Block >> << /O /Table /ColSpan 2 >> 1 ] >>", &[], "");
|
||||
let th = pdfcraft_cos::ObjRef::new(6, 0);
|
||||
set_tag_property(&mut doc, th, TagProperty::Scope, Some("Column")).unwrap();
|
||||
let Some(pdfcraft_cos::Object::Array(items)) = attributes(&doc, th) else { panic!("{:?}", attributes(&doc, th)) };
|
||||
assert_eq!(items.len(), 3, "{items:?}");
|
||||
assert_eq!(items[0].as_dict().and_then(|d| d.name(b"O")), Some(&b"Layout"[..]));
|
||||
let table = items[1].as_dict().unwrap();
|
||||
assert_eq!((table.int(b"ColSpan"), table.name(b"Scope")), (Some(2), Some(&b"Column"[..])));
|
||||
assert_eq!(items[2].as_int(), Some(1), "the revision number stays with its object");
|
||||
assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope.as_deref(), Some("Column"));
|
||||
// Cleared, the span stays.
|
||||
set_tag_property(&mut doc, th, TagProperty::Scope, None).unwrap();
|
||||
let Some(pdfcraft_cos::Object::Array(items)) = attributes(&doc, th) else { panic!() };
|
||||
assert_eq!(items[1].as_dict().map(|d| (d.int(b"ColSpan"), d.contains(b"Scope"))), Some((Some(2), false)));
|
||||
// A scope is Row, Column or Both, and only header cells have one.
|
||||
for bad in ["row", "Sideways", " Both"] {
|
||||
assert!(matches!(set_tag_property(&mut doc, th, TagProperty::Scope, Some(bad)), Err(TagError::InvalidScope)), "{bad}");
|
||||
}
|
||||
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(7, 0), TagProperty::Scope, Some("Row")), Err(TagError::WrongTagType(..))));
|
||||
}
|
||||
|
||||
/// With two Table attribute objects, the one holding the value is the one set and cleared
|
||||
/// (#4130 review): a value in the second isn't left behind or shadowed.
|
||||
#[test]
|
||||
fn the_table_attribute_object_holding_a_value_is_the_one_edited() {
|
||||
let mut doc = table_doc("<< /S /TH /A [ << /O /Table /ColSpan 2 >> << /O /Table /Scope /Row >> ] >>", &[], "");
|
||||
let th = pdfcraft_cos::ObjRef::new(6, 0);
|
||||
let scopes = |doc: &Document| -> Vec<Option<Vec<u8>>> {
|
||||
let Some(pdfcraft_cos::Object::Array(items)) = attributes(doc, th) else { return vec![] };
|
||||
items.iter().map(|i| i.as_dict().and_then(|d| d.name(b"Scope")).map(<[u8]>::to_vec)).collect()
|
||||
};
|
||||
set_tag_property(&mut doc, th, TagProperty::Scope, Some("Column")).unwrap();
|
||||
assert_eq!(scopes(&doc), [None, Some(b"Column".to_vec())]);
|
||||
assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope.as_deref(), Some("Column"));
|
||||
set_tag_property(&mut doc, th, TagProperty::Scope, None).unwrap();
|
||||
assert_eq!(scopes(&doc), [None], "the second object had only the scope: {:?}", attributes(&doc, th));
|
||||
assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_attribute_object_is_not_changed_for_other_tags() {
|
||||
// Both cells point at attribute object 8.
|
||||
let mut doc = table_doc("<< /S /TH /A 8 0 R >>", &["<< /O /Table /Scope /Row >>"], "");
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |d| d.set(b"A".to_vec(), pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))).unwrap();
|
||||
set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(6, 0), TagProperty::Scope, Some("Both")).unwrap();
|
||||
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(8, 0)).as_dict().and_then(|d| d.name(b"Scope")), Some(&b"Row"[..]));
|
||||
let a = attributes(&doc, pdfcraft_cos::ObjRef::new(6, 0)).unwrap();
|
||||
assert_eq!(a.as_dict().and_then(|d| d.name(b"Scope")), Some(&b"Both"[..]), "the cell's own copy: {a:?}");
|
||||
assert_eq!(attributes(&doc, pdfcraft_cos::ObjRef::new(7, 0)), Some(pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_hierarchy_keeps_indirect_parent_through_inline_tags() {
|
||||
let doc = structure_doc(&["<< /S /Document /K 4 0 R >>".into(), "<< /S /Sect /K << /S /Div /K 5 0 R >> >>".into(), "<< /S /Figure >>".into()]);
|
||||
|
||||
@@ -51,6 +51,11 @@ deterministic; the engine supplies them.
|
||||
signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so
|
||||
the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps
|
||||
that appearance matrix. The rotation is the page's `/Rotate` at placement.
|
||||
- Sticky notes and file attachments are not counterrotated: they carry the NoRotate flag and
|
||||
their icon is drawn in an unturned 20 × 20 box. A viewer keeps them upright on a turned page,
|
||||
about the upper-left corner of `/Rect` (ISO 32000-2 §12.5.3). PdfCraft's renderer does so
|
||||
(vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is
|
||||
drawn.
|
||||
|
||||
## Not yet
|
||||
|
||||
|
||||
+288
-43
@@ -180,23 +180,42 @@ fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
|
||||
color(d, b"IC").flatten().or(fallback)
|
||||
}
|
||||
|
||||
/// The border width (ISO 32000-2 §12.5.4): `/BS /W` when there is a border style dictionary, which
|
||||
/// makes `/Border` ignored (Table 166), else the third element of `/Border [hr vr w [dash]]`, read
|
||||
/// whether or not the optional fourth element, a dash array, follows. 1 when neither says, and
|
||||
/// when the number given is negative or not finite.
|
||||
pub(crate) fn border_width(d: &Dict) -> f64 {
|
||||
if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) {
|
||||
return w.max(0.0);
|
||||
}
|
||||
match nums(d, b"Border") {
|
||||
Some(b) if b.len() >= 3 => b[2].max(0.0),
|
||||
_ => 1.0,
|
||||
}
|
||||
let width = match d.get(b"BS").and_then(Object::as_dict) {
|
||||
Some(bs) => bs.get(b"W").and_then(Object::as_f64),
|
||||
None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_f64),
|
||||
};
|
||||
width.filter(|w| w.is_finite() && *w >= 0.0).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Dash pattern operator for a dashed border, if any.
|
||||
/// Elements a dash array may have before it is drawn solid instead: the lengths are written into
|
||||
/// the appearance as they are, so a file can't make it arbitrarily large.
|
||||
const MAX_DASH: usize = 64;
|
||||
|
||||
/// A dash array (§8.4.3.6) as drawn: every element finite and non-negative, not all zero (that
|
||||
/// pattern is invalid) and at most [`MAX_DASH`] long. `None` for anything else, which is drawn solid.
|
||||
fn dash_array(o: &Object) -> Option<Vec<f64>> {
|
||||
let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?;
|
||||
let v: Vec<f64> = a.iter().map(Object::as_f64).collect::<Option<_>>()?;
|
||||
(v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0)).then_some(v)
|
||||
}
|
||||
|
||||
/// Dash pattern operator for a dashed border, if any: `/BS /S /D` with its `/D` (default `[3]`,
|
||||
/// Table 168), or, without `/BS`, the dash array that may follow the width in `/Border` (Table 166).
|
||||
fn dash(d: &Dict) -> String {
|
||||
let Some(bs) = d.get(b"BS").and_then(|b| b.as_dict()) else { return String::new() };
|
||||
if bs.name(b"S") != Some(b"D") {
|
||||
return String::new();
|
||||
}
|
||||
let arr = nums(bs, b"D").filter(|a| !a.is_empty() && a.iter().all(|x| *x >= 0.0)).unwrap_or_else(|| vec![3.0]);
|
||||
let arr = match d.get(b"BS").and_then(Object::as_dict) {
|
||||
Some(bs) if bs.name(b"S") != Some(b"D") => return String::new(),
|
||||
Some(bs) => match bs.get(b"D") {
|
||||
None => Some(vec![3.0]),
|
||||
Some(o) => dash_array(o),
|
||||
},
|
||||
None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(3)).and_then(dash_array),
|
||||
};
|
||||
let Some(arr) = arr else { return String::new() };
|
||||
format!("[{}] 0 d\n", arr.iter().map(|x| n(*x)).collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
|
||||
@@ -299,6 +318,55 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
|
||||
Some((what, first_undrawable(&text)?))
|
||||
}
|
||||
|
||||
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
|
||||
fn helvetica_resource() -> Object {
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"Helv".to_vec(), Object::Dict(font));
|
||||
Object::Dict(fonts)
|
||||
}
|
||||
|
||||
/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for
|
||||
/// `/Sy /P` the symbol's baseline origin and font size (ISO 32000-2 Table 183).
|
||||
///
|
||||
/// The caret lies within `/Rect` less `/RD` (left, top, right, bottom), when `/RD` is four finite,
|
||||
/// non-negative numbers that leave a box; otherwise within `/Rect`. The ¶ sits to the right of the
|
||||
/// caret, its height a little over half the caret's, in the room `/RD` leaves on the right; when
|
||||
/// that is too narrow, the caret gives up to half its width and the symbol shrinks to what is left,
|
||||
/// so both stay inside `/Rect` (the form's `/BBox`).
|
||||
fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) {
|
||||
let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0));
|
||||
let mut caret = match rd {
|
||||
Some(rd) => [rect[0] + rd[0], rect[1] + rd[3], rect[2] - rd[2], rect[3] - rd[1]],
|
||||
None => rect,
|
||||
};
|
||||
if !(caret[2] > caret[0] && caret[3] > caret[1]) {
|
||||
caret = rect;
|
||||
}
|
||||
if d.name(b"Sy") != Some(b"P") {
|
||||
return (caret, None);
|
||||
}
|
||||
let size = (caret[3] - caret[1]) * 0.6;
|
||||
let (gap, glyph) = (size * 0.1, text_width("\u{b6}", size));
|
||||
let need = gap + glyph;
|
||||
let room = rect[2] - caret[2];
|
||||
if room < need {
|
||||
caret[2] -= (need - room).min((caret[2] - caret[0]) * 0.5);
|
||||
}
|
||||
let room = rect[2] - caret[2];
|
||||
let scale = if need > room { room / need } else { 1.0 };
|
||||
let size = size * scale;
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return (caret, None);
|
||||
}
|
||||
// The pilcrow descends about a sixth of the size below its baseline.
|
||||
(caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size)))
|
||||
}
|
||||
|
||||
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
|
||||
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
|
||||
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
|
||||
@@ -512,7 +580,8 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
b"Caret" => {
|
||||
// A filled caret: two curved flanks meeting at the top centre.
|
||||
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (caret, pilcrow) = caret_layout(d, rect);
|
||||
let [x0, y0, x1, y1] = caret;
|
||||
let (cx, h) = ((x0 + x1) / 2.0, y1 - y0);
|
||||
c.push_str(&format!(
|
||||
"{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n",
|
||||
@@ -532,11 +601,22 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
n(x1),
|
||||
n(y0)
|
||||
));
|
||||
if let Some((x, y, size)) = pilcrow {
|
||||
// `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour.
|
||||
// 0xB6 is the pilcrow in WinAnsiEncoding.
|
||||
let mut out = c.into_bytes();
|
||||
out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes());
|
||||
out.push(0xB6);
|
||||
out.extend_from_slice(b") Tj ET\n");
|
||||
res.set(b"Font".to_vec(), helvetica_resource());
|
||||
return Some(form(rect, &out, res));
|
||||
}
|
||||
}
|
||||
b"Ink" => {
|
||||
let col = stroke?;
|
||||
let list = d.get(b"InkList")?.as_array()?;
|
||||
c.push_str(&format!("{}{} w 1 J 1 j\n", rg_stroke(col), n(w)));
|
||||
// `/BS` gives an ink stroke its width and dash pattern (Table 182).
|
||||
c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d)));
|
||||
for s in list {
|
||||
let pts: Vec<f64> = s.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<_>>()?;
|
||||
let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
|
||||
@@ -670,7 +750,8 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
}
|
||||
let kind = crate::StampKind::from_name(other)?;
|
||||
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
|
||||
return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
|
||||
let sign_here = kind.group() == crate::StampGroup::SignHere;
|
||||
return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
|
||||
}
|
||||
}
|
||||
if turn != 0 {
|
||||
@@ -773,14 +854,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
y -= size * 1.2;
|
||||
}
|
||||
out.extend_from_slice(b"ET\n");
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"Helv".to_vec(), Object::Dict(font));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
res.set(b"Font".to_vec(), helvetica_resource());
|
||||
let mut stream = form(full, &out, res);
|
||||
if let Some(m) = matrix {
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
@@ -803,22 +877,101 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
// WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8.
|
||||
out.extend(literal(&win_ansi(&value)));
|
||||
out.extend_from_slice(b" Tj ET\n");
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"Helv".to_vec(), Object::Dict(font));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
res.set(b"Font".to_vec(), helvetica_resource());
|
||||
}
|
||||
}
|
||||
Some(form(rect, &out, res))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of
|
||||
/// their own and the palette's colour for their meaning.
|
||||
fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> {
|
||||
use crate::StampKind::*;
|
||||
let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease];
|
||||
if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) {
|
||||
return Some((kind.label(), kind.color()));
|
||||
}
|
||||
let (label, like) = match name {
|
||||
b"Experimental" => ("EXPERIMENTAL", Draft),
|
||||
b"AsIs" => ("AS IS", Draft),
|
||||
b"Expired" => ("EXPIRED", NotApproved),
|
||||
b"Sold" => ("SOLD", Approved),
|
||||
b"Departmental" => ("DEPARTMENTAL", Draft),
|
||||
b"TopSecret" => ("TOP SECRET", Confidential),
|
||||
_ => return None,
|
||||
};
|
||||
Some((label, like.color()))
|
||||
}
|
||||
|
||||
/// The neutral grey of a stamp whose name has no drawing.
|
||||
const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45];
|
||||
|
||||
/// The display appearance of a rubber stamp another program wrote without one: the stamp its
|
||||
/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the
|
||||
/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays
|
||||
/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp
|
||||
/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text.
|
||||
///
|
||||
/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so
|
||||
/// edits (restyling, flattening) never write this drawing into the file. `None` for any other
|
||||
/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is
|
||||
/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal
|
||||
/// appearance), or a `/Rect` that isn't finite or is under a point wide or high.
|
||||
pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
|
||||
let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline");
|
||||
if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") {
|
||||
return None;
|
||||
}
|
||||
let rect = nums(d, b"Rect")?;
|
||||
let [a, b, c, e] = rect.as_slice() else { return None };
|
||||
let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)];
|
||||
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
|
||||
let stroke = color(d, b"C").flatten();
|
||||
let mut res = Dict::new();
|
||||
if let Some(gs) = ext_gstate(opacity, false) {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
|
||||
let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp");
|
||||
let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice()));
|
||||
if let Some((label, col)) = name.and_then(standard_stamp) {
|
||||
return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res));
|
||||
}
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5);
|
||||
let mut c = String::new();
|
||||
if opacity < 1.0 {
|
||||
c.push_str("/GS0 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n",
|
||||
// Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour.
|
||||
rg_stroke(PLACEHOLDER_GREY),
|
||||
n(a0),
|
||||
n(b0),
|
||||
n(a1 - a0),
|
||||
n(b1 - b0),
|
||||
n(a0),
|
||||
n(b0),
|
||||
n(a1),
|
||||
n(b1),
|
||||
n(a0),
|
||||
n(b1),
|
||||
n(a1),
|
||||
n(b0)
|
||||
));
|
||||
Some(form(rect, c.as_bytes(), res))
|
||||
}
|
||||
|
||||
/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold
|
||||
/// capitals, and the dynamic stamps' "By … at …" line.
|
||||
fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream {
|
||||
fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let h = y1 - y0;
|
||||
let mut c = String::new();
|
||||
@@ -827,7 +980,7 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa
|
||||
}
|
||||
let lw = (h * 0.07).clamp(1.0, 3.0);
|
||||
let inset = lw / 2.0 + 0.5;
|
||||
if kind.group() == crate::StampGroup::SignHere {
|
||||
if sign_here {
|
||||
// A tag pointing left, filled, with white text.
|
||||
let tip = h * 0.45;
|
||||
c.push_str(&format!(
|
||||
@@ -893,16 +1046,15 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa
|
||||
n(b0)
|
||||
));
|
||||
}
|
||||
let label = kind.label();
|
||||
let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) };
|
||||
let text_area = if kind.group() == crate::StampGroup::SignHere { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) };
|
||||
let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) };
|
||||
let mut size = title_h;
|
||||
let tw = text_width(label, size) * 1.12;
|
||||
if tw > text_area.1 - text_area.0 - 4.0 {
|
||||
size *= (text_area.1 - text_area.0 - 4.0) / tw;
|
||||
}
|
||||
let tw = text_width(label, size) * 1.12;
|
||||
let text_col = if kind.group() == crate::StampGroup::SignHere { [1.0, 1.0, 1.0] } else { col };
|
||||
let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col };
|
||||
let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 };
|
||||
let mut out = c.into_bytes();
|
||||
out.extend(
|
||||
@@ -975,14 +1127,107 @@ fn attach_icon(name: &str, col: Rgb) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// A closed circle path (four Bézier quarters) centred on (`x`, `y`).
|
||||
fn circle_path(x: f64, y: f64, r: f64) -> String {
|
||||
let k = r * 0.5523;
|
||||
let p = |v: f64| n(v);
|
||||
format!(
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n",
|
||||
p(x + r),
|
||||
p(y),
|
||||
p(x + r),
|
||||
p(y + k),
|
||||
p(x + k),
|
||||
p(y + r),
|
||||
p(x),
|
||||
p(y + r),
|
||||
p(x - k),
|
||||
p(y + r),
|
||||
p(x - r),
|
||||
p(y + k),
|
||||
p(x - r),
|
||||
p(y),
|
||||
p(x - r),
|
||||
p(y - k),
|
||||
p(x - k),
|
||||
p(y - r),
|
||||
p(x),
|
||||
p(y - r),
|
||||
p(x + k),
|
||||
p(y - r),
|
||||
p(x + r),
|
||||
p(y - k),
|
||||
p(x + r),
|
||||
p(y)
|
||||
)
|
||||
}
|
||||
|
||||
/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of
|
||||
/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO
|
||||
/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`.
|
||||
fn note_icon(name: &str, col: Rgb) -> String {
|
||||
let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col));
|
||||
if name == "Comment" {
|
||||
s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n");
|
||||
s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n");
|
||||
} else {
|
||||
s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n");
|
||||
s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n");
|
||||
match name {
|
||||
"Comment" => {
|
||||
s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n");
|
||||
s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n");
|
||||
}
|
||||
// A key lying flat: a round bow with a hole, a shaft and two bits.
|
||||
"Key" => {
|
||||
s.push_str(&circle_path(5.5, 10.0, 4.5));
|
||||
s.push_str(&circle_path(5.0, 10.0, 1.5));
|
||||
s.push_str(
|
||||
"9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n",
|
||||
);
|
||||
}
|
||||
// A disc with a question mark.
|
||||
"Help" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 8.5));
|
||||
s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n");
|
||||
}
|
||||
// An insertion caret.
|
||||
"Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"),
|
||||
// A caret over a bar: a new paragraph goes here.
|
||||
"NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"),
|
||||
// A pilcrow: a filled bowl and two stems.
|
||||
"Paragraph" => {
|
||||
s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n");
|
||||
s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n");
|
||||
}
|
||||
// A tick.
|
||||
"Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"),
|
||||
// A ring.
|
||||
"Circle" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 8.5));
|
||||
s.push_str(&circle_path(10.0, 10.0, 5.0));
|
||||
s.push_str("B*\n");
|
||||
}
|
||||
// A thick X.
|
||||
"Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"),
|
||||
// A ring with sighting lines through it.
|
||||
"CrossHairs" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 7.0));
|
||||
s.push_str(&circle_path(10.0, 10.0, 4.5));
|
||||
s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n");
|
||||
}
|
||||
// A five-pointed star.
|
||||
"Star" => {
|
||||
let points: Vec<String> = (0..10)
|
||||
.map(|i| {
|
||||
let r = if i % 2 == 0 { 9.5 } else { 3.9 };
|
||||
let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0;
|
||||
format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin()))
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l ")));
|
||||
}
|
||||
// Block arrows.
|
||||
"RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"),
|
||||
"UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"),
|
||||
_ => {
|
||||
s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n");
|
||||
s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
+86
-11
@@ -66,7 +66,9 @@ impl Markup {
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard text-annotation icon names (§12.5.6.4, Table 175).
|
||||
/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2
|
||||
/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which
|
||||
/// the same table allows ("Additional names may be supported as well").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum NoteIcon {
|
||||
#[default]
|
||||
@@ -77,6 +79,13 @@ pub enum NoteIcon {
|
||||
Key,
|
||||
NewParagraph,
|
||||
Paragraph,
|
||||
Check,
|
||||
Circle,
|
||||
Cross,
|
||||
CrossHairs,
|
||||
Star,
|
||||
RightArrow,
|
||||
UpArrow,
|
||||
}
|
||||
|
||||
impl NoteIcon {
|
||||
@@ -89,11 +98,36 @@ impl NoteIcon {
|
||||
NoteIcon::Key => "Key",
|
||||
NoteIcon::NewParagraph => "NewParagraph",
|
||||
NoteIcon::Paragraph => "Paragraph",
|
||||
NoteIcon::Check => "Check",
|
||||
NoteIcon::Circle => "Circle",
|
||||
NoteIcon::Cross => "Cross",
|
||||
NoteIcon::CrossHairs => "CrossHairs",
|
||||
NoteIcon::Star => "Star",
|
||||
NoteIcon::RightArrow => "RightArrow",
|
||||
NoteIcon::UpArrow => "UpArrow",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every icon, the standard names first.
|
||||
pub const ALL: [NoteIcon; 14] = [
|
||||
Self::Comment,
|
||||
Self::Note,
|
||||
Self::Help,
|
||||
Self::Insert,
|
||||
Self::Key,
|
||||
Self::NewParagraph,
|
||||
Self::Paragraph,
|
||||
Self::Check,
|
||||
Self::Circle,
|
||||
Self::Cross,
|
||||
Self::CrossHairs,
|
||||
Self::Star,
|
||||
Self::RightArrow,
|
||||
Self::UpArrow,
|
||||
];
|
||||
|
||||
pub fn from_name(n: &str) -> Option<Self> {
|
||||
[Self::Comment, Self::Note, Self::Help, Self::Insert, Self::Key, Self::NewParagraph, Self::Paragraph].into_iter().find(|i| i.name() == n)
|
||||
Self::ALL.into_iter().find(|i| i.name() == n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,17 +699,20 @@ pub fn page_refs(doc: &Document) -> Result<Vec<ObjRef>, AnnotError> {
|
||||
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
|
||||
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
|
||||
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
|
||||
/// Cycles end at the seen check and at most a million nodes are visited.
|
||||
/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once,
|
||||
/// and at most a million nodes and four million `/Kids` entries are read.
|
||||
struct PageWalk<'a> {
|
||||
doc: &'a Document,
|
||||
seen: std::collections::HashSet<ObjRef>,
|
||||
pending: Vec<(std::sync::Arc<Object>, usize)>,
|
||||
/// `/Kids` entries read so far.
|
||||
examined: usize,
|
||||
}
|
||||
|
||||
impl<'a> PageWalk<'a> {
|
||||
fn new(doc: &'a Document, top: ObjRef) -> Self {
|
||||
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
|
||||
Self { doc, seen: Default::default(), pending: vec![(top, 0)] }
|
||||
Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,8 +727,9 @@ impl Iterator for PageWalk<'_> {
|
||||
};
|
||||
let node = kid.as_ref();
|
||||
*next += 1;
|
||||
self.examined += 1;
|
||||
let Some(node) = node else { continue };
|
||||
if self.seen.len() >= 1_000_000 {
|
||||
if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 {
|
||||
self.pending.clear();
|
||||
return None;
|
||||
}
|
||||
@@ -700,8 +738,15 @@ impl Iterator for PageWalk<'_> {
|
||||
}
|
||||
let obj = self.doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
match d.get(b"Kids").map(|k| self.doc.resolve(k)) {
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)),
|
||||
match d.get(b"Kids") {
|
||||
// A `/Kids` array object shared by several nodes is read under the first: under
|
||||
// the others it would only list nodes already seen.
|
||||
Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => {
|
||||
if self.seen.insert(*array) {
|
||||
self.pending.push((self.doc.get(*array), 0));
|
||||
}
|
||||
}
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)),
|
||||
_ => return Some(node),
|
||||
}
|
||||
}
|
||||
@@ -717,7 +762,16 @@ fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
|
||||
fn annots(doc: &Document, page: ObjRef) -> Vec<Object> {
|
||||
let obj = doc.get(page);
|
||||
let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() };
|
||||
doc.resolve(a).as_array().cloned().unwrap_or_default()
|
||||
let list = doc.resolve(a).as_array().cloned().unwrap_or_default();
|
||||
#[cfg(test)]
|
||||
ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len()));
|
||||
list
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Entries copied out of pages' `/Annots` arrays.
|
||||
static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect.
|
||||
@@ -788,6 +842,17 @@ fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
|
||||
d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with
|
||||
/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags).
|
||||
fn drawn_upright(doc: &Document, d: &Dict) -> bool {
|
||||
let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() {
|
||||
Some(Object::Int(i)) => *i,
|
||||
Some(Object::Real(r)) => *r as i64,
|
||||
_ => 0,
|
||||
};
|
||||
u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0)
|
||||
}
|
||||
|
||||
/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden).
|
||||
pub fn has_visible_fill_sign(doc: &Document) -> bool {
|
||||
let Ok(pages) = page_refs(doc) else { return false };
|
||||
@@ -956,7 +1021,7 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
if strokes.iter().all(|s| s.is_empty()) || !strokes.iter().flatten().all(|p| finite(p)) {
|
||||
if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) {
|
||||
return Err(bad("drawing (no points)"));
|
||||
}
|
||||
// Strokes of three or more points are drawn as curves, which stay within their
|
||||
@@ -1167,7 +1232,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"PCOutline".to_vec(), Object::Array(list));
|
||||
}
|
||||
Shape::CustomStamp { name, picture, image, size, .. } => {
|
||||
let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
|
||||
let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
|
||||
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
|
||||
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
|
||||
@@ -1532,7 +1597,14 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
|
||||
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
|
||||
continue;
|
||||
}
|
||||
let Some(stream) = build_appearance(doc, &d) else { continue };
|
||||
// Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its
|
||||
// `/AP`, if any, has no `/N` to keep here.
|
||||
let other_stamp = || {
|
||||
let mut bare = d.clone();
|
||||
bare.remove(b"AP");
|
||||
appearance::display_stamp(&bare)
|
||||
};
|
||||
let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue };
|
||||
cache.insert(r, &stream);
|
||||
// Fails only when `r` isn't a dictionary, which the build just ruled out.
|
||||
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
|
||||
@@ -2208,6 +2280,8 @@ pub struct Summary {
|
||||
pub intent: Option<String>,
|
||||
/// A Fill & Sign mark ([`is_fill_sign`]).
|
||||
pub fill_sign: bool,
|
||||
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
|
||||
pub no_rotate: bool,
|
||||
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
|
||||
/// (`/Vertices`), in user space; empty for other comments.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
@@ -2290,6 +2364,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
|
||||
fill_sign: is_fill_sign(doc, d),
|
||||
no_rotate: drawn_upright(doc, d),
|
||||
points,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,6 +255,16 @@ fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> {
|
||||
/// Add a link over `rect` (user space). Returns its index in the page's `/Annots`.
|
||||
pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<usize, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let obj = new_link(doc, p, rect, action, style)?;
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(obj));
|
||||
let index = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// A link annotation object for page `p`, not yet on the page's `/Annots`.
|
||||
fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<ObjRef, AnnotError> {
|
||||
let r = checked_rect(rect)?;
|
||||
if matches!(action, LinkAction::Other(_)) {
|
||||
return Err(AnnotError::Invalid("choose a page or a web address".into()));
|
||||
@@ -267,12 +277,7 @@ pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction,
|
||||
d.set(b"F".to_vec(), Object::Int(4));
|
||||
write_style(&mut d, style);
|
||||
write_action(doc, &mut d, action)?;
|
||||
let obj = doc.add(Object::Dict(d));
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(obj));
|
||||
let index = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
Ok(index)
|
||||
Ok(doc.add(Object::Dict(d)))
|
||||
}
|
||||
|
||||
fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
|
||||
@@ -437,12 +442,40 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
|
||||
/// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per
|
||||
/// line rectangle). Returns how many links were added.
|
||||
pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result<usize, AnnotError> {
|
||||
let pages = page_refs(doc)?;
|
||||
// Check the whole batch first, so a bad entry fails it before any link object is created.
|
||||
for (page, rects, uri) in links {
|
||||
pages.get(*page).ok_or(AnnotError::NoSuchPage(*page))?;
|
||||
if uri.trim().is_empty() {
|
||||
return Err(AnnotError::Invalid("type the web address first".into()));
|
||||
}
|
||||
for r in rects {
|
||||
checked_rect(*r)?;
|
||||
}
|
||||
}
|
||||
// Each page's `/Annots` is extended once, with its new links in the order given.
|
||||
let mut added: Vec<(ObjRef, Vec<Object>)> = Vec::new();
|
||||
let mut slot: std::collections::HashMap<ObjRef, usize> = std::collections::HashMap::new();
|
||||
let mut n = 0;
|
||||
for (page, rects, uri) in links {
|
||||
let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?;
|
||||
let action = LinkAction::Uri(uri.clone());
|
||||
for r in rects {
|
||||
add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?;
|
||||
let obj = new_link(doc, p, *r, &action, style)?;
|
||||
let k = *slot.entry(p).or_insert_with(|| {
|
||||
added.push((p, Vec::new()));
|
||||
added.len() - 1
|
||||
});
|
||||
if let Some((_, list)) = added.get_mut(k) {
|
||||
list.push(Object::Ref(obj));
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for (p, new) in added {
|
||||
let mut list = annots(doc, p);
|
||||
list.extend(new);
|
||||
set_annots(doc, p, list)?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
+302
-2
@@ -1014,6 +1014,89 @@ fn a_note_without_an_appearance_is_shown_at_icon_size() {
|
||||
assert_eq!(rects(&doc), original, "the source document is unchanged");
|
||||
}
|
||||
|
||||
/// The fourteen rubber stamp names viewers draw without an appearance (ISO 32000-2 Table 184).
|
||||
const TABLE_184_STAMPS: [(&str, &str); 14] = [
|
||||
("Approved", "APPROVED"),
|
||||
("Experimental", "EXPERIMENTAL"),
|
||||
("NotApproved", "NOT APPROVED"),
|
||||
("AsIs", "AS IS"),
|
||||
("Expired", "EXPIRED"),
|
||||
("NotForPublicRelease", "NOT FOR PUBLIC RELEASE"),
|
||||
("Confidential", "CONFIDENTIAL"),
|
||||
("Final", "FINAL"),
|
||||
("Sold", "SOLD"),
|
||||
("Departmental", "DEPARTMENTAL"),
|
||||
("ForComment", "FOR COMMENT"),
|
||||
("TopSecret", "TOP SECRET"),
|
||||
("Draft", "DRAFT"),
|
||||
("ForPublicRelease", "FOR PUBLIC RELEASE"),
|
||||
];
|
||||
|
||||
fn stamp_dict(extra: &str) -> Dict {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
format!("<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] {extra} >>").as_bytes(),
|
||||
]);
|
||||
list(&doc, 0).remove(0)
|
||||
}
|
||||
|
||||
fn drawn(d: &Dict) -> String {
|
||||
String::from_utf8_lossy(&appearance::display_stamp(d).expect("a stamp is drawn").decoded().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// A stamp another program wrote without an appearance draws the stamp its `/Name` names, Draft
|
||||
/// when it has none, and a frame with both diagonals for any other name, never the name itself.
|
||||
#[test]
|
||||
fn a_stamp_without_an_appearance_draws_the_stamp_it_names() {
|
||||
for (name, label) in TABLE_184_STAMPS {
|
||||
let content = drawn(&stamp_dict(&format!("/Name /{name}")));
|
||||
assert!(content.contains(&format!("({label}) Tj")), "{name}: {content}");
|
||||
}
|
||||
assert!(drawn(&stamp_dict("")).contains("(DRAFT) Tj"), "Table 184's default");
|
||||
assert!(drawn(&stamp_dict("/IT /Stamp")).contains("(DRAFT) Tj"), "a stamp intent keeps the default");
|
||||
// Any other name, a name in another case (names are compared byte for byte, §7.3.5), and an
|
||||
// image stamp (`/IT`, which has no `/Name` to default): the frame and its diagonals.
|
||||
for extra in ["/Name /MyCustomStamp", "/Name /draft", "/Name /APPROVED", "/IT /StampImage", "/Name /PCVoid"] {
|
||||
let content = drawn(&stamp_dict(extra));
|
||||
assert!(!content.contains("Tj") && content.contains(" re\n") && content.matches(" l\n").count() == 2, "{extra}: {content}");
|
||||
}
|
||||
// `/C` colours the stamp, as it does PdfCraft's own.
|
||||
assert!(drawn(&stamp_dict("/Name /Final /C [1 0 1]")).contains("1 0 1 RG"));
|
||||
// The placeholder stays grey whatever the `/C`: it is not the stamp, so it takes none of its colour.
|
||||
let placeholder = drawn(&stamp_dict("/Name /MyCustomStamp /C [1 0 0]"));
|
||||
assert!(placeholder.contains("0.45 0.45 0.45 RG") && !placeholder.contains("1 0 0 RG"), "{placeholder}");
|
||||
// A stamp's own artwork is never replaced, and the drawing is for display only: edits
|
||||
// (restyling, flattening) still find nothing they may write.
|
||||
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none());
|
||||
assert!(appearance::build(&stamp_dict("/Name /Draft")).is_none());
|
||||
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /PCStamp true")).is_none(), "PdfCraft's own stamps are drawn by `build`");
|
||||
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /Rect [5 5 5 50]")).is_none(), "no width");
|
||||
}
|
||||
|
||||
/// The display copy draws a stamp that has no normal appearance, and keeps one that has.
|
||||
#[test]
|
||||
fn the_display_copy_draws_stamps_without_a_normal_appearance_only() {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] /Name /TopSecret >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [20 140 140 200] /Name /Draft /AP << /N 8 0 R >> >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [20 240 140 300] /Name /Sold /AP << /D 8 0 R >> >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [20 340 140 400] /Name /Unheard >>",
|
||||
b"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 8 >>\nstream\n1 0 0 rg\nendstream",
|
||||
]);
|
||||
let display = with_missing_appearances(&doc).expect("three stamps need an appearance");
|
||||
let shown = list(&display, 0);
|
||||
assert!(ap_content(&display, &shown[0]).contains("(TOP SECRET) Tj"));
|
||||
assert_eq!(shown[1].get(b"AP"), list(&doc, 0)[1].get(b"AP"), "an existing appearance is kept");
|
||||
assert!(ap_content(&display, &shown[2]).contains("(SOLD) Tj"), "an /AP without /N has nothing to keep");
|
||||
assert!(!ap_content(&display, &shown[3]).contains("Tj"), "an unknown name gets the placeholder");
|
||||
assert!(list(&doc, 0)[0].get(b"AP").is_none(), "the document itself is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_line_ending_is_drawn() {
|
||||
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
|
||||
@@ -1316,9 +1399,9 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() {
|
||||
.expect("strips");
|
||||
let mut cache = AppearanceCache::default();
|
||||
let first = with_missing_appearances_cached(&doc, &mut cache).expect("builds");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 0), "only the note needs an appearance");
|
||||
assert_eq!((cache.builds(), cache.hits()), (2, 0), "the note and the file's stamp need an appearance");
|
||||
let second = with_missing_appearances_cached(&doc, &mut cache).expect("reuses");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 1), "nothing is redrawn");
|
||||
assert_eq!((cache.builds(), cache.hits()), (2, 2), "nothing is redrawn");
|
||||
let [a, b] = [&first, &second].map(|copy| ap_content(copy, &list(copy, 0)[idx]));
|
||||
assert!(!a.is_empty());
|
||||
assert_eq!(a, b, "the reused appearance is byte-identical");
|
||||
@@ -1505,6 +1588,61 @@ fn typed_signature_uses_nonzero_winding_fill() {
|
||||
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
|
||||
}
|
||||
|
||||
/// Many page tree nodes sharing one `/Kids` array read it once, not once per node.
|
||||
#[test]
|
||||
fn page_walk_reads_a_shared_kids_array_once() {
|
||||
let mut doc = fixture();
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 });
|
||||
let shared = doc.add(Object::Array([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(2_000).collect()));
|
||||
let nodes: Vec<Object> = (0..1_000)
|
||||
.map(|_| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
d.set(b"Kids".to_vec(), Object::Ref(shared));
|
||||
Object::Ref(doc.add(d))
|
||||
})
|
||||
.collect();
|
||||
doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(nodes))).unwrap();
|
||||
let mut walk = PageWalk::new(&doc, root);
|
||||
let pages: Vec<ObjRef> = walk.by_ref().collect();
|
||||
assert_eq!(pages, [first, second]);
|
||||
assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined);
|
||||
}
|
||||
|
||||
/// Adding many links to a page extends its `/Annots` once, not once per link.
|
||||
#[test]
|
||||
fn many_links_extend_a_page_annotation_list_once() {
|
||||
let mut doc = fixture();
|
||||
let before = annots(&doc, page_refs(&doc).unwrap()[0]).len();
|
||||
let n = 2_000;
|
||||
let rects: Vec<[f64; 4]> = (0..n).map(|i| [10.0, i as f64 * 0.1, 50.0, i as f64 * 0.1 + 5.0]).collect();
|
||||
ANNOTS_ENTRIES_COPIED.with(|c| c.set(0));
|
||||
let added = links::add_many(&mut doc, &[(0, rects, "https://example.com".into())], &links::LinkStyle::default()).unwrap();
|
||||
let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get);
|
||||
assert_eq!(added, n);
|
||||
assert_eq!(annots(&doc, page_refs(&doc).unwrap()[0]).len(), before + n);
|
||||
assert!(copied <= 4 * n, "{copied} annotation entries copied for {n} links");
|
||||
}
|
||||
|
||||
/// A batch with one bad entry adds nothing: no link objects are left behind unattached.
|
||||
#[test]
|
||||
fn a_link_batch_with_a_bad_entry_creates_no_objects() {
|
||||
let mut doc = fixture();
|
||||
let page = page_refs(&doc).unwrap()[0];
|
||||
let before = (annots(&doc, page).len(), doc.scan_objects().count());
|
||||
let good = [10.0, 10.0, 50.0, 20.0];
|
||||
let style = links::LinkStyle::default();
|
||||
for bad in [
|
||||
vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good, [f64::NAN, 0.0, 10.0, 10.0]], "https://example.com".into())],
|
||||
vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good], " ".into())],
|
||||
vec![(0, vec![good], "https://example.com".to_string()), (99, vec![good], "https://example.com".into())],
|
||||
] {
|
||||
assert!(links::add_many(&mut doc, &bad, &style).is_err());
|
||||
assert_eq!((annots(&doc, page).len(), doc.scan_objects().count()), before);
|
||||
}
|
||||
}
|
||||
|
||||
fn doc_of(objs: &[String]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
@@ -1567,3 +1705,165 @@ fn cloud_outline_has_a_bump_budget() {
|
||||
let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count();
|
||||
assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps");
|
||||
}
|
||||
|
||||
/// A note's drawn icon (its built appearance's content) for `/Name` (none when `None`) and `/C`.
|
||||
fn note_drawing(name: Option<&str>, color: [f64; 3]) -> Vec<u8> {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Text"));
|
||||
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 20.0, 20.0].map(Object::Real).to_vec()));
|
||||
d.set(b"C".to_vec(), Object::Array(color.map(Object::Real).to_vec()));
|
||||
if let Some(n) = name {
|
||||
d.set(b"Name".to_vec(), Object::name(n));
|
||||
}
|
||||
crate::appearance::build(&d).expect("a note is drawn").decoded().unwrap()
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §12.5.6.4, Table 175: a viewer shall provide icons for the seven standard names,
|
||||
/// and may support more. Each of the fourteen names PdfCraft offers draws a shape of its own, so
|
||||
/// a note shows the icon it names; a name it doesn't know, and a note without one, draw `Note`
|
||||
/// (the table's default).
|
||||
#[test]
|
||||
fn every_note_icon_draws_its_own_shape() {
|
||||
let yellow = [1.0, 0.82, 0.0];
|
||||
let drawn: Vec<Vec<u8>> = NoteIcon::ALL.iter().map(|i| note_drawing(Some(i.name()), yellow)).collect();
|
||||
for (i, a) in drawn.iter().enumerate() {
|
||||
for (j, b) in drawn.iter().enumerate().skip(i + 1) {
|
||||
assert_ne!(a, b, "{} and {} look alike", NoteIcon::ALL[i].name(), NoteIcon::ALL[j].name());
|
||||
}
|
||||
}
|
||||
let note = note_drawing(Some("Note"), yellow);
|
||||
assert_eq!(note_drawing(Some("Sparkle"), yellow), note, "an unknown name");
|
||||
assert_eq!(note_drawing(None, yellow), note, "no name");
|
||||
for icon in NoteIcon::ALL {
|
||||
assert_eq!(NoteIcon::from_name(icon.name()), Some(icon));
|
||||
}
|
||||
}
|
||||
|
||||
/// A note named with one of the further icons reads as that icon, and a colour change redraws it
|
||||
/// as itself, not as a page.
|
||||
#[test]
|
||||
fn a_star_note_keeps_its_star_when_recoloured() {
|
||||
let mut doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
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();
|
||||
let doc = reopen(&doc);
|
||||
let d = &list(&doc, 0)[0];
|
||||
assert_eq!(d.name(b"Name"), Some(&b"Star"[..]));
|
||||
let n = d.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
|
||||
let Object::Stream(s) = &*doc.get(n) else { panic!() };
|
||||
assert_eq!(s.decoded().unwrap(), note_drawing(Some("Star"), [0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// The built appearance's content for an annotation dictionary written in PDF syntax.
|
||||
fn content_of(src: &str) -> String {
|
||||
let doc = document_of(&[src.as_bytes()]);
|
||||
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("a dictionary");
|
||||
let stream = appearance::build(&d).expect("drawn");
|
||||
String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 166: `/Border [hr vr w [dash]]`; the optional fourth element, a dash array,
|
||||
/// used to hide the width (drawn 1 pt solid instead of 3 pt dashed).
|
||||
#[test]
|
||||
fn legacy_border_width_and_dash_are_drawn() {
|
||||
let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>"));
|
||||
let c = square("/Border [0 0 3 [6 3]]");
|
||||
assert!(c.contains("3 w") && c.contains("[6 3] 0 d"), "{c}");
|
||||
assert!(c.contains("1.5 1.5 97 97 re"), "the stroke is inset by half its width: {c}");
|
||||
let c = square("/Border [0 0 3]");
|
||||
assert!(c.contains("3 w") && !c.contains(" d\n"), "no dash array: solid: {c}");
|
||||
// `/BS` overrides `/Border` (Table 166), for the width and the dashes alike.
|
||||
let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 >>");
|
||||
assert!(c.contains("2 w") && !c.contains(" d\n"), "{c}");
|
||||
let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 /S /D /D [4 1] >>");
|
||||
assert!(c.contains("2 w") && c.contains("[4 1] 0 d"), "{c}");
|
||||
// A /BS without /W means its default width 1, not the ignored /Border's.
|
||||
let c = square("/Border [0 0 3] /BS << /S /S >>");
|
||||
assert!(c.contains("1 w") && !c.contains("3 w"), "{c}");
|
||||
// A FreeText border and a line take the legacy dash array too.
|
||||
let c = content_of("<< /Subtype /FreeText /Rect [0 0 100 40] /Contents (x) /Border [0 0 2 [5 2]] >>");
|
||||
assert!(c.contains("2 w") && c.contains("[5 2] 0 d"), "{c}");
|
||||
let c = content_of("<< /Subtype /Line /Rect [0 0 100 100] /L [10 10 90 90] /C [0 0 1] /Border [0 0 2 [5 2]] >>");
|
||||
assert!(c.contains("[5 2] 0 d"), "{c}");
|
||||
}
|
||||
|
||||
/// Dash arrays that can't be drawn (§8.4.3.6: all zeros is invalid; negative or non-numeric
|
||||
/// lengths, and absurdly long arrays) draw solid, without a panic. A non-finite or negative
|
||||
/// width falls back to 1.
|
||||
#[test]
|
||||
fn bad_dash_arrays_draw_solid() {
|
||||
let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>"));
|
||||
let long = format!("[{}]", vec!["1"; 65].join(" "));
|
||||
for bad in ["[0 0]", "[-3 2]", "[]", "[(a) 2]", "5", long.as_str()] {
|
||||
let c = square(&format!("/Border [0 0 3 {bad}]"));
|
||||
assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}");
|
||||
let c = square(&format!("/BS << /W 3 /S /D /D {bad} >>"));
|
||||
assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}");
|
||||
}
|
||||
assert!(square(&format!("/Border [0 0 3 [{}]]", vec!["1"; 64].join(" "))).contains(" 0 d\n"), "64 elements are drawn");
|
||||
// `/S /D` without `/D` uses the default `[3]` (Table 168).
|
||||
assert!(square("/BS << /W 3 /S /D >>").contains("[3] 0 d"));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].map(Object::Real).to_vec()));
|
||||
d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec()));
|
||||
for w in [f64::NAN, f64::INFINITY, -2.0] {
|
||||
let dash = Object::Array([f64::NAN, 2.0].map(Object::Real).to_vec());
|
||||
d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(w), dash]));
|
||||
let c = String::from_utf8_lossy(&appearance::build(&d).unwrap().decoded().unwrap()).into_owned();
|
||||
assert!(c.contains("1 w") && !c.contains(" d\n") && !c.contains("NaN") && !c.contains("inf"), "{w}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Table 182: an ink annotation's `/BS` gives the width and dash pattern its paths are drawn with.
|
||||
#[test]
|
||||
fn ink_draws_its_border_style_dashes() {
|
||||
let ink = |style: &str| content_of(&format!("<< /Subtype /Ink /Rect [0 0 100 100] /C [1 0 0] /InkList [[5 5 50 95 95 5]] {style} >>"));
|
||||
let c = ink("/BS << /W 3 /S /D /D [6 3] >>");
|
||||
assert!(c.contains("3 w 1 J 1 j\n[6 3] 0 d"), "{c}");
|
||||
assert!(!ink("/BS << /W 3 >>").contains(" d\n"));
|
||||
assert!(ink("/Border [0 0 2 [4 4]]").contains("[4 4] 0 d"));
|
||||
}
|
||||
|
||||
/// Table 183: `/Sy /P` associates a new paragraph symbol (¶) with the caret. It is drawn to the
|
||||
/// caret's right in the caret's colour, inside `/Rect`; `/Sy /None` (the default) draws none.
|
||||
#[test]
|
||||
fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() {
|
||||
let caret = |extra: &str| {
|
||||
let doc = document_of(&[format!("<< /Subtype /Caret /Rect [30 30 60 60] /C [0.9 0.1 0.1] {extra} >>").as_bytes()]);
|
||||
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
|
||||
let s = appearance::build(&d).unwrap();
|
||||
(s.decoded().unwrap(), s.dict.get(b"Resources").and_then(Object::as_dict).is_some_and(|r| r.contains(b"Font")))
|
||||
};
|
||||
let has_pilcrow = |c: &[u8]| c.windows(5).any(|w| w == b"(\xb6) T");
|
||||
let (c, font) = caret("/Sy /P");
|
||||
assert!(has_pilcrow(&c) && font, "{}", String::from_utf8_lossy(&c));
|
||||
let text = String::from_utf8_lossy(&c).into_owned();
|
||||
assert!(text.starts_with("0.9 0.1 0.1 rg"), "{text}");
|
||||
// The caret gives up room on the right for the symbol, which stays within /Rect.
|
||||
let td: Vec<f64> = text.split("Tf ").nth(1).unwrap().split_whitespace().take(2).map(|v| v.parse().unwrap()).collect();
|
||||
let size: f64 = text.split("/Helv ").nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap();
|
||||
assert!(td[0] > 45.0 && td[0] + appearance::text_width("\u{b6}", size) <= 60.0 + 1e-6 && td[1] >= 30.0, "{text}");
|
||||
assert!(!text.contains("60 30 c h f"), "the caret no longer reaches the right edge: {text}");
|
||||
for none in ["/Sy /None", ""] {
|
||||
let (c, font) = caret(none);
|
||||
assert!(!has_pilcrow(&c) && !font, "{none}");
|
||||
assert!(String::from_utf8_lossy(&c).contains("60 30 c h f"), "{none}: the caret fills /Rect as before");
|
||||
}
|
||||
// With /RD leaving room on the right, the caret keeps its box and the symbol fits beside it.
|
||||
let (c, _) = caret("/Sy /P /RD [0 0 15 0]");
|
||||
let text = String::from_utf8_lossy(&c).into_owned();
|
||||
assert!(text.contains("45 30 c h f") && has_pilcrow(&c), "{text}");
|
||||
// Malformed /RD and a degenerate /Rect don't panic.
|
||||
for rd in ["/RD [40 0 40 0]", "/RD [-1 0 0 0]", "/RD [1 2]", "/RD (x)"] {
|
||||
assert!(has_pilcrow(&caret(&format!("/Sy /P {rd}")).0), "{rd}");
|
||||
}
|
||||
let doc = document_of(&[b"<< /Subtype /Caret /Rect [30 30 30 30] /Sy /P >>"]);
|
||||
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
|
||||
assert!(appearance::build(&d).is_some());
|
||||
}
|
||||
@@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden:
|
||||
less than 40% of its words (a sliver of a long value) is not flagged.
|
||||
|
||||
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
|
||||
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
|
||||
than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-word
|
||||
comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no
|
||||
findings".
|
||||
|
||||
A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with
|
||||
an empty `covered_text`: the audit hands out no text that copying would refuse.
|
||||
|
||||
Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each
|
||||
rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like
|
||||
|
||||
@@ -147,10 +147,11 @@ impl<'a> Walker<'a> {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(gs.clone()),
|
||||
b"Q" => {
|
||||
// The current path is not graphics state (ISO 32000-2 §8.4.1): `re Q f` still
|
||||
// fills the rectangle, whose corners are already in user space.
|
||||
if let Some(g) = stack.pop() {
|
||||
*gs = g;
|
||||
}
|
||||
path = Path::Empty;
|
||||
}
|
||||
b"cm" => {
|
||||
let before = gs.ctm;
|
||||
@@ -261,7 +262,7 @@ impl<'a> Walker<'a> {
|
||||
let resolved = self.doc.resolve(entry);
|
||||
let Object::Stream(stream) = &*resolved else { return Ok(()) };
|
||||
let dict = &stream.dict;
|
||||
if dict.name(b"Subtype") != Some(b"Form") {
|
||||
if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") {
|
||||
return Ok(());
|
||||
}
|
||||
let matrix = dict
|
||||
|
||||
+150
-33
@@ -65,7 +65,7 @@ pub struct AuditFinding {
|
||||
/// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`.
|
||||
pub rect: [f64; 4],
|
||||
/// The extractable text the cover hides, lines joined with single spaces and trimmed.
|
||||
/// Possibly empty when nothing decodable remains under the cover.
|
||||
/// Empty when the document's permissions withhold copying (see [`audit_redactions`]).
|
||||
pub covered_text: String,
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ pub enum AuditError {
|
||||
TooManyForms,
|
||||
#[error("page has a malformed /Annots entry")]
|
||||
BadAnnots,
|
||||
#[error("page has more covers and text lines than the audit compares")]
|
||||
TooManyComparisons,
|
||||
#[error("covered text exceeds the audit's report budget")]
|
||||
TooMuchOutput,
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
#[error(transparent)]
|
||||
@@ -97,49 +101,122 @@ const WORD_FRACTION: f64 = 0.5;
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
|
||||
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
|
||||
/// content that cannot be decoded is returned as an error, never as an empty finding list.
|
||||
/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is
|
||||
/// returned as an error, never as an empty finding list.
|
||||
///
|
||||
/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are
|
||||
/// still reported but their `covered_text` is empty: the audit hands out no text that copying
|
||||
/// would refuse.
|
||||
pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError> {
|
||||
audit(doc, Limits::DEFAULT)
|
||||
}
|
||||
|
||||
/// Work and output bounds for one audit.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
/// Cover-against-line comparisons per page, counting a line once per word box it has (at
|
||||
/// least once), since [`covers`] walks them.
|
||||
comparisons: usize,
|
||||
/// Bytes of covered text the findings report.
|
||||
covered_text: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// Every cover is compared with every line of its page, and each finding repeats the text
|
||||
/// it covers, so both grow with the square of the content: a few megabytes of covers over
|
||||
/// one long line would otherwise take hours and gigabytes.
|
||||
const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 };
|
||||
}
|
||||
|
||||
/// The work and report the audit has spent against its [`Limits`].
|
||||
struct Budget {
|
||||
limits: Limits,
|
||||
comparisons: usize,
|
||||
/// What comparing one cover with every line of the current page costs.
|
||||
page_cost: usize,
|
||||
covered_text: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Resets the comparison count for a page with these `lines`. A line costs its word boxes
|
||||
/// or its runs, whichever are more (at least one): a cover check walks the boxes, and the
|
||||
/// covered text walks the runs, which can draw no words at all (`( ) Tj`).
|
||||
fn start_page(&mut self, lines: &[TextLine]) {
|
||||
self.comparisons = 0;
|
||||
self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(line.runs().count()).max(1)));
|
||||
}
|
||||
|
||||
/// The text `cover` hides in the `lines` that `hides` ([`covered_words`]), joined with single
|
||||
/// spaces, or `None` when they show no text. Without `copying` the text is empty and never
|
||||
/// assembled: one long line under many covers would otherwise be copied once per cover,
|
||||
/// outside both bounds.
|
||||
fn hidden_text(
|
||||
&mut self,
|
||||
lines: &[TextLine],
|
||||
copying: bool,
|
||||
cover: &[f64; 4],
|
||||
hides: impl Fn(&TextLine) -> bool,
|
||||
) -> Result<Option<String>, AuditError> {
|
||||
self.comparisons = self.comparisons.saturating_add(self.page_cost);
|
||||
if self.comparisons > self.limits.comparisons {
|
||||
return Err(AuditError::TooManyComparisons);
|
||||
}
|
||||
if !copying {
|
||||
return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new));
|
||||
}
|
||||
let mut covered = String::new();
|
||||
for line in lines.iter().filter(|l| hides(l)) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered_words(cover, line, &mut covered);
|
||||
}
|
||||
Ok((!covered.trim().is_empty()).then_some(covered))
|
||||
}
|
||||
|
||||
/// The text a finding reports for `covered`: none when copying is withheld. Charges what it
|
||||
/// reports against the output bound.
|
||||
fn report_text(&mut self, covered: String, copying: bool) -> Result<String, AuditError> {
|
||||
let reported = if copying { covered } else { String::new() };
|
||||
self.covered_text = self.covered_text.saturating_add(reported.len());
|
||||
if self.covered_text > self.limits.covered_text {
|
||||
return Err(AuditError::TooMuchOutput);
|
||||
}
|
||||
Ok(reported)
|
||||
}
|
||||
}
|
||||
|
||||
fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError> {
|
||||
let copying = doc.permissions().is_none_or(|p| p.copy());
|
||||
let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 };
|
||||
let mut out = Vec::new();
|
||||
for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() {
|
||||
// The glyphs themselves: `/ActualText` is the file's own claim about them, and an empty
|
||||
// or substitute one must not hide text a cover sits on.
|
||||
let lines = pdfcraft_edit::glyph_text_lines(doc, page).map_err(|e| AuditError::Text(n, e.to_string()))?;
|
||||
budget.start_page(&lines);
|
||||
// 1. Opaque filled rectangles over extractable text.
|
||||
for r in cover::filled_rects(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if is_cover(&r, line) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered.push_str(line.text.trim());
|
||||
}
|
||||
}
|
||||
if !covered.trim().is_empty() {
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
|
||||
if let Some(covered) = budget.hidden_text(&lines, copying, &r.rect, |line| is_cover(&r, line))? {
|
||||
let covered_text = budget.report_text(covered, copying)?;
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text });
|
||||
}
|
||||
}
|
||||
// 2. Annotation covers over extractable text.
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
let hidden = match &annot {
|
||||
AnnotationCover::Redact(rect) => covers(rect, line),
|
||||
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
|
||||
};
|
||||
if hidden {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered.push_str(line.text.trim());
|
||||
}
|
||||
}
|
||||
if !covered.trim().is_empty() {
|
||||
let cover = match &annot {
|
||||
AnnotationCover::Redact(rect) | AnnotationCover::Square { rect, .. } => *rect,
|
||||
};
|
||||
if let Some(covered) = budget.hidden_text(&lines, copying, &cover, |line| match &annot {
|
||||
AnnotationCover::Redact(rect) => covers(rect, line),
|
||||
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
|
||||
})? {
|
||||
let (kind, rect) = match annot {
|
||||
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
|
||||
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
|
||||
};
|
||||
out.push(AuditFinding { page: n, kind, rect, covered_text: covered });
|
||||
let covered_text = budget.report_text(covered, copying)?;
|
||||
out.push(AuditFinding { page: n, kind, rect, covered_text });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,11 +224,13 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
}
|
||||
|
||||
/// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly
|
||||
/// after the line, overlaps it substantially, and is dark enough against the line's color to
|
||||
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
|
||||
/// contrast check and are not flagged.
|
||||
/// after the start of the line, overlaps it substantially, and is dark enough against the line's
|
||||
/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail
|
||||
/// the contrast check and are not flagged. A line can join runs painted before and after the
|
||||
/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable
|
||||
/// too.
|
||||
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
|
||||
@@ -183,6 +262,34 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
inked || (boxed && (line.ink.is_empty() || word_intersects()))
|
||||
}
|
||||
|
||||
/// Appends the text of `line` that `cover` lies on to `out`: the runs whose words it overlaps
|
||||
/// (#4164), so the words of the line painted beside the cover, which stay visible, are not
|
||||
/// reported as hidden. Neighbouring runs are joined as the line joins them, so a word split
|
||||
/// across runs stays one word; runs with others left out between them are spaced. The whole
|
||||
/// line when its runs can't say: a single run, a line whose text was reordered for reading, or
|
||||
/// a cover that overlaps the line's box but none of its words.
|
||||
fn covered_words(cover: &[f64; 4], line: &TextLine, out: &mut String) {
|
||||
let overlaps = |w: &[f64; 4]| area(&[cover[0].max(w[0]), cover[1].max(w[1]), cover[2].min(w[2]), cover[3].min(w[3])]) > 0.0;
|
||||
let mut words = String::new();
|
||||
let mut last = None;
|
||||
for (i, (text, ink, spaced)) in line.runs().enumerate() {
|
||||
if text.trim().is_empty() || !ink.iter().any(overlaps) {
|
||||
continue;
|
||||
}
|
||||
let apart = last.is_some_and(|l: usize| l.saturating_add(1) != i || spaced);
|
||||
if apart && !words.ends_with(char::is_whitespace) && !text.starts_with(char::is_whitespace) {
|
||||
words.push(' ');
|
||||
}
|
||||
words.push_str(text);
|
||||
last = Some(i);
|
||||
}
|
||||
if line.runs().nth(1).is_none() || last.is_none() {
|
||||
out.push_str(line.text.trim());
|
||||
} else {
|
||||
out.push_str(words.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/// The area of a rectangle, 0 when it is empty or inverted.
|
||||
fn area(r: &[f64; 4]) -> f64 {
|
||||
(r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0)
|
||||
@@ -233,7 +340,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
|
||||
continue;
|
||||
}
|
||||
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
|
||||
match dict.name(b"Subtype") {
|
||||
match subtype(doc, &dict)?.as_deref() {
|
||||
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
|
||||
Some(b"Square") => {
|
||||
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
@@ -253,6 +360,16 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be
|
||||
/// loaded fails the audit: the subtype decides whether the object is checked at all.
|
||||
fn subtype(doc: &Document, dict: &Dict) -> Result<Option<Vec<u8>>, AuditError> {
|
||||
let name = match dict.get(b"Subtype") {
|
||||
Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec),
|
||||
other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec),
|
||||
};
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> {
|
||||
let values: Vec<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
|
||||
if !values.iter().all(|v| v.is_finite()) {
|
||||
|
||||
@@ -593,6 +593,188 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current path is not graphics state: a rectangle built before `Q` and filled after it
|
||||
/// paints the box, so it is a cover.
|
||||
#[test]
|
||||
fn rectangle_filled_after_restore_is_a_cover() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\nq 100 688 220 32 re Q f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
|
||||
}
|
||||
|
||||
/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact`
|
||||
/// annotation and a covering form XObject named that way are still checked.
|
||||
#[test]
|
||||
fn indirect_subtype_is_resolved() {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n");
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>");
|
||||
b.add("/Redact");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
||||
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>");
|
||||
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n");
|
||||
b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]");
|
||||
b.add(FONT);
|
||||
b.add("/Form");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
}
|
||||
|
||||
/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover
|
||||
/// still hides the words painted before it.
|
||||
#[test]
|
||||
fn later_text_on_the_line_does_not_clear_an_earlier_cover() {
|
||||
let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n";
|
||||
let doc = open(one_page(content, ""));
|
||||
let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text");
|
||||
assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
|
||||
let findings = audit_redactions(&doc).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
|
||||
}
|
||||
|
||||
/// #4164: a cover over part of a line reports the words under it, not the words of the same
|
||||
/// line painted beside it, which stay visible.
|
||||
#[test]
|
||||
fn covered_text_leaves_out_the_words_beside_the_cover() {
|
||||
let covered = |content: &[u8]| {
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
findings.into_iter().map(|f| f.covered_text).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["SECRET"]);
|
||||
// A word shown in two runs with nothing between them stays one word (review on #4198).
|
||||
assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SE) Tj (CRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["SECRET"]);
|
||||
|
||||
// The same for an unapplied redaction mark over the first run only. A mark over both runs
|
||||
// reports both, with the space the line has between them (`tail` is set apart here).
|
||||
for (rect, expected) in [("[98 688 192 720]", "SECRET"), ("[98 688 310 720]", "SECRET tail")] {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\nBT /F1 24 Tf 250 700 Td (tail) Tj ET\n");
|
||||
b.add(FONT);
|
||||
b.add(&format!("<< /Type /Annot /Subtype /Redact /Rect {rect} >>"));
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::<Vec<_>>(), [(FindingKind::UnappliedMark, expected)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Many covers over the same long line repeat its text in every finding: a small file must not
|
||||
/// expand into an unbounded report. Past the budget the audit fails rather than truncate.
|
||||
#[test]
|
||||
fn covered_text_across_findings_is_bounded() {
|
||||
let text = "X".repeat(60_000);
|
||||
let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes();
|
||||
for i in 0..1_200 {
|
||||
content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes());
|
||||
}
|
||||
let result = audit_redactions(&open(one_page(&content, "")));
|
||||
assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len()));
|
||||
}
|
||||
|
||||
/// Covers compared against lines are bounded per page, so content that is quadratic in its
|
||||
/// covers and lines fails the audit instead of running for hours. A line costs one comparison per
|
||||
/// word: 20 covers against 20 two-word lines are 800.
|
||||
#[test]
|
||||
fn cover_and_line_comparisons_are_bounded() {
|
||||
let mut content = Vec::new();
|
||||
for i in 0..20 {
|
||||
content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes());
|
||||
}
|
||||
for _ in 0..20 {
|
||||
content.extend_from_slice(b"1 g 0 0 10 10 re f\n");
|
||||
}
|
||||
let doc = open(one_page(&content, ""));
|
||||
let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX };
|
||||
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
|
||||
let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX };
|
||||
assert!(crate::audit(&doc, limits).expect("within the budget").is_empty());
|
||||
}
|
||||
|
||||
/// A run that draws no word still costs a comparison: the covered text walks every run of a
|
||||
/// covered line, so a word followed by many `( ) Tj` must not be checked for free (review on
|
||||
/// #4198).
|
||||
#[test]
|
||||
fn runs_without_words_count_against_the_comparison_bound() {
|
||||
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj".to_vec();
|
||||
content.extend_from_slice(&b" ( ) Tj".repeat(50));
|
||||
content.extend_from_slice(
|
||||
b" ET
|
||||
0 g
|
||||
98 688 94 32 re f
|
||||
",
|
||||
);
|
||||
let doc = open(one_page(&content, ""));
|
||||
let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text");
|
||||
assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::<Vec<_>>(), [(1, 51)]);
|
||||
let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX };
|
||||
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
|
||||
let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX };
|
||||
assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1);
|
||||
}
|
||||
|
||||
/// Covered text is document text: a document whose permissions withhold copying reports where
|
||||
/// its covers are but not what they hide, unless it was opened with the owner password.
|
||||
#[test]
|
||||
fn copy_restricted_documents_withhold_covered_text() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
|
||||
let mut doc = open(one_page(content, ""));
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: -1 ^ 0b1_0000,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.expect("encrypt");
|
||||
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save"));
|
||||
let user = Document::open_with_password(bytes.clone(), None).expect("open");
|
||||
let findings = audit_redactions(&user).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "");
|
||||
// The output bound counts the text reported, so withheld text costs nothing.
|
||||
let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 };
|
||||
assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1);
|
||||
let owner = Document::open_with_password(bytes, Some("owner")).expect("open");
|
||||
let findings = audit_redactions(&owner).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
||||
let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() };
|
||||
assert!(crate::audit(&owner, exact).is_ok());
|
||||
let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact };
|
||||
assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput)));
|
||||
}
|
||||
|
||||
/// Withheld text is never assembled: covers over one long line would otherwise copy the line once
|
||||
/// per cover, a volume neither the comparison nor the output bound charges.
|
||||
#[test]
|
||||
fn withheld_covered_text_is_not_assembled() {
|
||||
const PAGE: [f64; 4] = [0.0, 0.0, 612.0, 792.0];
|
||||
let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", ""));
|
||||
let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text");
|
||||
let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 };
|
||||
budget.start_page(&lines);
|
||||
let withheld = budget.hidden_text(&lines, false, &PAGE, |_| true).expect("within the budget");
|
||||
assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it");
|
||||
assert_eq!(budget.hidden_text(&lines, false, &PAGE, |_| false).expect("within the budget"), None);
|
||||
let copied = budget.hidden_text(&lines, true, &PAGE, |_| true).expect("within the budget");
|
||||
assert_eq!(copied.as_deref(), Some("SECRET DATA 123"));
|
||||
}
|
||||
|
||||
/// `/ActualText` is metadata the file's author controls: an empty or substitute replacement must
|
||||
/// not hide the glyphs a cover sits on.
|
||||
#[test]
|
||||
|
||||
@@ -504,7 +504,7 @@ impl Automation {
|
||||
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
|
||||
"parent": t.parent.map(|p| json!({ "object": p.num, "generation": p.generation })), "depth": t.depth,
|
||||
"title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt,
|
||||
"language": t.language, "page": t.page.map(|p| p + 1),
|
||||
"language": t.language, "summary": t.summary, "scope": t.scope, "page": t.page.map(|p| p + 1),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -575,6 +575,8 @@ impl Automation {
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
|
||||
/// unless the models read it) and `skip_text_pages`, each left at its default when absent.
|
||||
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
|
||||
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
|
||||
if let Some(d) = a.opt_num("dpi")? {
|
||||
@@ -592,6 +594,9 @@ impl Automation {
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Recognizes text in each of `paths` and writes the result under the same file name in
|
||||
/// `folder`, reporting per file its output, word count and skipped pages, or its error. A
|
||||
/// path outside the root refuses the whole call before any work starts.
|
||||
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
@@ -601,15 +606,26 @@ impl Automation {
|
||||
for p in &paths {
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
// Two inputs that would write the same output file overwrite each other while both report
|
||||
// success, so the whole batch is refused here: before the folder is created, anything is
|
||||
// read, or the OCR engines load.
|
||||
let targets: Vec<std::path::PathBuf> = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect();
|
||||
let shared = shared_file_names(&targets);
|
||||
if !shared.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.",
|
||||
shared.join(", ")
|
||||
)));
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
|
||||
let name = input_file_name(p);
|
||||
let result =
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| {
|
||||
pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true)
|
||||
});
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|
||||
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|
||||
);
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
@@ -623,6 +639,8 @@ impl Automation {
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
/// Recognizes text on the open document's `pages` (every page when absent) and adds it,
|
||||
/// reporting the total word count and, per page, why it was skipped or what it found.
|
||||
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
@@ -642,6 +660,8 @@ impl Automation {
|
||||
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
|
||||
}
|
||||
|
||||
/// Whether text recognition's models are installed, the directories searched for them (in
|
||||
/// order), and the languages it reads, each as a code and a name.
|
||||
pub(crate) fn ocr_status(&self) -> Result<Value> {
|
||||
use pdfcraft_engine::ocr;
|
||||
let dirs: Vec<String> = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
|
||||
@@ -650,6 +670,39 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name an input is written under: its own file name, or `document.pdf` when it has none.
|
||||
fn input_file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into())
|
||||
}
|
||||
|
||||
/// The output file names that two or more of `targets` share, each spelling listed once, in input
|
||||
/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on
|
||||
/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode
|
||||
/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are
|
||||
/// not caught. The folder is the same for every target, so only the names are compared.
|
||||
fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec<String> {
|
||||
let mut groups: std::collections::BTreeMap<String, (usize, Vec<String>)> = std::collections::BTreeMap::new();
|
||||
for target in targets {
|
||||
let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let key = if cfg!(windows) {
|
||||
name.trim_end_matches(['.', ' ']).to_lowercase()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// Case only; NFC and NFD spellings of one name stay distinct here (a known limit).
|
||||
name.to_lowercase()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new()));
|
||||
*count += 1;
|
||||
if !spellings.contains(&name) {
|
||||
spellings.push(name);
|
||||
}
|
||||
}
|
||||
groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect()
|
||||
}
|
||||
|
||||
/// A script's request to the viewer as the JSON that running a script reports; a side panel
|
||||
/// goes by its fixed id.
|
||||
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
use pdfcraft_engine::js::Request as R;
|
||||
match r {
|
||||
@@ -661,6 +714,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
R::Focus(f) => json!({ "focus": f }),
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,7 +723,9 @@ impl Automation {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "result": o.result }))
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
|
||||
@@ -700,7 +756,7 @@ impl Automation {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
}
|
||||
let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?;
|
||||
let target = self.resolve(a.str("path")?, true)?;
|
||||
@@ -845,7 +901,7 @@ impl Automation {
|
||||
let result = self
|
||||
.resolve(p, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|src| std::fs::read(&src).map_err(|e| e.to_string()))
|
||||
.and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string()))
|
||||
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
@@ -1042,3 +1098,21 @@ mod review_html_tests {
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_output_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_output_names_follow_the_default_filesystem() {
|
||||
let targets = |names: &[&str]| -> Vec<std::path::PathBuf> { names.iter().map(|n| std::path::Path::new("out").join(n)).collect() };
|
||||
// A spelling that repeats is one shared name, listed once.
|
||||
assert_eq!(shared_file_names(&targets(&["a.pdf", "b.pdf", "a.pdf"])), vec!["a.pdf".to_string()]);
|
||||
assert!(shared_file_names(&targets(&["a.pdf", "b.pdf"])).is_empty());
|
||||
// Case is ignored on Windows and macOS only; a trailing dot is ignored on Windows only.
|
||||
let case = shared_file_names(&targets(&["scan.pdf", "Scan.PDF"]));
|
||||
let dot = shared_file_names(&targets(&["scan.pdf", "scan.pdf."]));
|
||||
assert_eq!(case.len(), if cfg!(any(windows, target_os = "macos")) { 2 } else { 0 }, "{case:?}");
|
||||
assert_eq!(dot.len(), if cfg!(windows) { 2 } else { 0 }, "{dot:?}");
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ impl<'a> Recipe<'a> {
|
||||
}
|
||||
|
||||
fn accepts(&self, path: &Path) -> bool {
|
||||
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
let extension = path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
|
||||
extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str()))
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ impl<'a> Recipe<'a> {
|
||||
if input.len() as u64 > MAX_INPUT {
|
||||
return Err(failed("input exceeds 512 MiB"));
|
||||
}
|
||||
let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| failed("input name is not UTF-8"))?;
|
||||
let name = path.file_name().and_then(std::ffi::OsStr::to_str).ok_or_else(|| failed("input name is not UTF-8"))?;
|
||||
let mut bytes = Arc::new(input);
|
||||
let mut log = Vec::new();
|
||||
for step in &self.steps {
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
//! Geometry follows the automation convention: points from the top-left of the displayed page,
|
||||
//! y down. It is converted to PDF user space (crop box, `/Rotate`) here.
|
||||
|
||||
use pdfcraft_engine::{
|
||||
Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
|
||||
};
|
||||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
|
||||
use pdfcraft_render::{Annotation, PageInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -13,6 +11,8 @@ use crate::{Args, Automation, Content, DEFAULT_DPI, MAX_DPI, Result, ToolError,
|
||||
|
||||
/// Author used when a tool call names none.
|
||||
pub(crate) const DEFAULT_AUTHOR: &str = "PdfCraft";
|
||||
/// The largest side of a stamp's box, in points: the largest page PDF allows (ISO 32000-2 Annex C).
|
||||
const MAX_STAMP_SIDE: f64 = 14_400.0;
|
||||
|
||||
pub(crate) fn parse_color(s: &str) -> Result<Rgb> {
|
||||
let named = match s.to_ascii_lowercase().as_str() {
|
||||
@@ -79,13 +79,11 @@ fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
|
||||
}
|
||||
|
||||
/// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the
|
||||
/// view point (x, y): the top-left (`[x0, y1]`) of the icon's square in user space. Converting
|
||||
/// the point alone is only right on unrotated pages — under `/Rotate` that corner of the
|
||||
/// displayed square is another corner of the user-space one, and the icon lands one icon-width
|
||||
/// away from where it was asked for.
|
||||
/// view point (x, y): the upper-left corner (`[x0, y1]`) of its `/Rect`. Notes and attachments
|
||||
/// are NoRotate, so on a turned page the icon is drawn upright with its top-left at that corner
|
||||
/// (ISO 32000-2 §12.5.3), and `comment_list` reports that square (`Annotation::shown_rect`).
|
||||
fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
|
||||
let r = rect_to_user(p, [x, y, x + NOTE_SIZE, y + NOTE_SIZE]);
|
||||
[r[0], r[3]]
|
||||
to_user(p, x, y)
|
||||
}
|
||||
|
||||
/// A user-space rectangle as the displayed-page rectangle every tool reports and accepts:
|
||||
@@ -181,8 +179,7 @@ impl Automation {
|
||||
"author": c.author,
|
||||
"contents": c.contents,
|
||||
"modified": c.modified,
|
||||
// Unchanged on a page the viewer could not read (#2660), as `doc_info` reports it.
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.rect),
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
|
||||
"color": c.color.map(hex),
|
||||
});
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
@@ -385,7 +382,7 @@ impl Automation {
|
||||
"attachment" => {
|
||||
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let data = crate::read_input(&path)?;
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let icon = match a.opt_str("icon")? {
|
||||
Some(n) => pdfcraft_engine::AttachIcon::from_name(n)
|
||||
@@ -638,7 +635,12 @@ impl Automation {
|
||||
edits.push(Edit::FontSizeAnnotation { page, index, size });
|
||||
}
|
||||
if let Some(r) = a.nums::<4>("rect")? {
|
||||
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
|
||||
// The rectangle as comment_list reports it: where the comment is drawn.
|
||||
let doc = self.doc(a)?;
|
||||
let rect = rect_to_user(&info, r);
|
||||
let rect =
|
||||
doc.info.annotations.iter().find(|c| c.page == page && c.index == index).map_or(rect, |c| c.rect_for_shown(rect, info.rotation));
|
||||
edits.push(Edit::ResizeAnnotation { page, index, rect });
|
||||
}
|
||||
if let Some([dx, dy]) = a.nums::<2>("move")? {
|
||||
let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy));
|
||||
@@ -674,19 +676,34 @@ impl Automation {
|
||||
|
||||
impl Automation {
|
||||
/// A custom stamp: a picture file (PDF page or image) centred at `at`, at its natural size
|
||||
/// (at most 200 pt) or `width` points wide.
|
||||
/// (at most 200 pt), or fitted inside `rect`, keeping its shape and centred (#4077).
|
||||
pub(crate) fn stamp_custom(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let info = self.doc(a)?.info.pages[page].clone();
|
||||
let rect = match (a.nums::<4>("rect")?, a.get("at")) {
|
||||
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass at (a point) or rect (a box), not both".into())),
|
||||
(Some(r), None) => {
|
||||
let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs());
|
||||
if !((1.0..=MAX_STAMP_SIDE).contains(&w) && (1.0..=MAX_STAMP_SIDE).contains(&h)) {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_STAMP_SIDE} pt"
|
||||
)));
|
||||
}
|
||||
rect_to_user(&info, r)
|
||||
}
|
||||
(None, _) => {
|
||||
let [x, y] = a.need::<2>("at", "a stamp (its centre, or pass rect)")?;
|
||||
let [ux, uy] = to_user(&info, x, y);
|
||||
[ux, uy, ux, uy]
|
||||
}
|
||||
};
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
|
||||
let [x, y] = a.need::<2>("at", "a stamp (its centre)")?;
|
||||
let [ux, uy] = to_user(&info, x, y);
|
||||
let edit = Edit::AddCustomStamp {
|
||||
page,
|
||||
rect: [ux, uy, ux, uy],
|
||||
rect,
|
||||
name,
|
||||
file: pdfcraft_engine::MarkFile {
|
||||
name: file,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the
|
||||
//! displayed page (y down), like every other tool.
|
||||
|
||||
use pdfcraft_engine::{AddedContent, AddedText, Edit, FontFamily, TextAlign};
|
||||
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::comments::parse_color;
|
||||
@@ -61,6 +61,12 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
|
||||
other => return Err(bad(format!("unknown align {other:?}"))),
|
||||
};
|
||||
}
|
||||
if let Some(c) = a.opt_num("char_spacing")? {
|
||||
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
|
||||
return Err(bad(format!("char_spacing must be between -{ADDED_CHAR_SPACING_LIMIT} and {ADDED_CHAR_SPACING_LIMIT} points")));
|
||||
}
|
||||
t.char_spacing = c;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -85,6 +91,7 @@ impl Automation {
|
||||
v["size"] = json!(t.size);
|
||||
v["bold"] = json!(t.bold);
|
||||
v["italic"] = json!(t.italic);
|
||||
v["char_spacing"] = json!(t.char_spacing);
|
||||
}
|
||||
AddedContent::Image(_) => v["type"] = json!("image"),
|
||||
}
|
||||
@@ -118,7 +125,7 @@ impl Automation {
|
||||
let page = self.page(a)?;
|
||||
let ph = self.doc(a)?.info.pages[page].height as f64;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph));
|
||||
let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?;
|
||||
@@ -164,8 +171,8 @@ impl Automation {
|
||||
if a.get("text").is_some() {
|
||||
return Err(bad("images have no text"));
|
||||
}
|
||||
if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) {
|
||||
return Err(bad("font, size, bold, italic, color and align apply to text"));
|
||||
if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
|
||||
return Err(bad("font, size, bold, italic, color, align and char_spacing apply to text"));
|
||||
}
|
||||
if let Some(deg) = a.opt_int("rotate")? {
|
||||
if deg.rem_euclid(90) != 0 {
|
||||
@@ -188,7 +195,7 @@ impl Automation {
|
||||
}
|
||||
if let Some(p) = a.opt_str("image")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// Box and transforms first, then the new picture.
|
||||
self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?;
|
||||
@@ -203,6 +210,63 @@ impl Automation {
|
||||
let (page, index, _, _) = self.item(a)?;
|
||||
self.apply(a, Edit::DeleteContent { page, index })
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy, then Paste: an added item onto `to_page` of `to_doc` (its own page and
|
||||
/// document by default), as it is, at `at` (its top-left) or where it was, stepped down and to
|
||||
/// the right past copies of it already on that page.
|
||||
pub(crate) fn content_copy(&mut self, a: &Args) -> Result<Value> {
|
||||
let (page, index, content, _) = self.item(a)?;
|
||||
let source = self.doc(a)?;
|
||||
source.check_copying()?;
|
||||
let from = source.id;
|
||||
let file = match &content {
|
||||
AddedContent::Text(_) => None,
|
||||
AddedContent::Image(_) => {
|
||||
let (ext, bytes) = source.added_image_file(page, index).map_err(failed)?;
|
||||
Some((format!("Pasted image.{ext}"), std::sync::Arc::new(bytes)))
|
||||
}
|
||||
};
|
||||
let to_doc = match a.opt_int("to_doc")? {
|
||||
Some(d) => {
|
||||
let d = u64::try_from(d).map_err(|_| bad("to_doc must be positive"))?;
|
||||
self.session.get(pdfcraft_engine::DocId(d)).ok_or_else(|| failed(format!("no open document with id {d} (see doc_list)")))?.id
|
||||
}
|
||||
None => from,
|
||||
};
|
||||
let doc = self.session.get(to_doc).ok_or_else(|| failed("the document is no longer open"))?;
|
||||
let n = doc.info.pages.len();
|
||||
let to_page = match a.opt_int("to_page")? {
|
||||
Some(p) => usize::try_from(p)
|
||||
.ok()
|
||||
.and_then(|p| p.checked_sub(1))
|
||||
.filter(|p| *p < n)
|
||||
.ok_or_else(|| bad(format!("to_page {p} is out of range: the document has {n} pages")))?,
|
||||
None if page < n => page,
|
||||
None => return Err(bad(format!("page {} is out of range in the target: it has {n} pages; pass to_page", page + 1))),
|
||||
};
|
||||
let info = doc.info.pages.get(to_page).ok_or_else(|| bad("the page no longer exists"))?;
|
||||
let (pw, ph) = (f64::from(info.width), f64::from(info.height));
|
||||
let at = match a.get("at") {
|
||||
Some(v) => {
|
||||
let p: Option<Vec<f64>> = v.as_array().and_then(|x| x.iter().map(Value::as_f64).collect());
|
||||
let [x, y]: [f64; 2] = p.and_then(|p| p.try_into().ok()).ok_or_else(|| bad("at must be [x, y]"))?;
|
||||
Some([x, ph - y])
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let on_page = || doc.added().iter().filter(|x| x.page == to_page);
|
||||
let rect = match at {
|
||||
Some(at) => pdfcraft_engine::added_paste_rect(content.rect(), [pw, ph], 0, Some(at)),
|
||||
None => pdfcraft_engine::added_paste_place(on_page().map(|x| &x.content), &content, [pw, ph]),
|
||||
};
|
||||
let before = on_page().count();
|
||||
let target = json!({ "doc": to_doc.0 });
|
||||
let mut out = self.apply(&Args(&target), Edit::PasteContent { page: to_page, content: content.with_rect(rect), file })?;
|
||||
out["page"] = json!(to_page + 1);
|
||||
out["index"] = json!(before + 1);
|
||||
out["rect"] = json!(to_view(rect, ph).map(|x| (x * 100.0).round() / 100.0));
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
|
||||
@@ -345,7 +345,8 @@ mod tests {
|
||||
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
|
||||
// inputs and out_dir, walks only inside them (skipping links) and resolves every
|
||||
// discovered file again before `process` reads it or `publish` writes it.
|
||||
const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
||||
const RESOLVED_BY_CALLER: &[&str] =
|
||||
&["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = vec![];
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
@@ -365,7 +366,8 @@ mod tests {
|
||||
|| !(trimmed.contains("std::fs")
|
||||
|| trimmed.contains("File::open")
|
||||
|| trimmed.contains("File::create")
|
||||
|| trimmed.contains("OpenOptions"))
|
||||
|| trimmed.contains("OpenOptions")
|
||||
|| trimmed.contains("read_input"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -288,6 +288,23 @@ impl Automation {
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Fill in form".into(), edits } };
|
||||
let mut out = self.apply(a, edit)?;
|
||||
out["filled"] = json!(values.len());
|
||||
// A value the box cuts off at a fixed font size is saved whole but shows only in part:
|
||||
// said, not silent (#4018).
|
||||
let doc = self.doc(a)?;
|
||||
let cut: Vec<Value> = values
|
||||
.keys()
|
||||
.filter_map(|name| doc.field_overflow(name).map(|(page, o)| (name, page, o)))
|
||||
.map(|(name, page, o)| {
|
||||
let page = page.map(|p| format!(" (page {})", p.saturating_add(1))).unwrap_or_default();
|
||||
json!(format!("form field {name:?}{page}: {o}, so the box shows only part of it (the whole value is saved)"))
|
||||
})
|
||||
.collect();
|
||||
if !cut.is_empty() {
|
||||
match out.get_mut("warnings").and_then(Value::as_array_mut) {
|
||||
Some(w) => w.extend(cut),
|
||||
None => out["warnings"] = Value::Array(cut),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -684,7 +701,7 @@ impl Automation {
|
||||
|
||||
pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count());
|
||||
let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?;
|
||||
|
||||
+167
-15
@@ -383,6 +383,36 @@ impl Automation {
|
||||
let (path, page) = (a.path("path")?, self.page(&a)?);
|
||||
self.apply(&a, Edit::SetBookmarkPage { path, page })?
|
||||
}
|
||||
"bookmark_set_style" => {
|
||||
let path = a.path("path")?;
|
||||
// Left out keeps the colour; [0, 0, 0] is the default black.
|
||||
let color = match a.get("color") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let rgb: Option<Vec<f64>> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect());
|
||||
match rgb.as_deref() {
|
||||
Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]),
|
||||
_ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())),
|
||||
}
|
||||
}
|
||||
};
|
||||
let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?);
|
||||
if color.is_none() && bold.is_none() && italic.is_none() {
|
||||
return Err(ToolError::InvalidArgs("give color, bold or italic".into()));
|
||||
}
|
||||
let mut edits = Vec::new();
|
||||
if let Some(color) = color {
|
||||
edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) });
|
||||
}
|
||||
if bold.is_some() || italic.is_some() {
|
||||
edits.push(Edit::SetBookmarkStyle { path, bold, italic });
|
||||
}
|
||||
// A colour and a style given together are one step to undo, applied all or nothing.
|
||||
match edits.len() {
|
||||
1 => self.apply(&a, edits.remove(0))?,
|
||||
_ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?,
|
||||
}
|
||||
}
|
||||
"bookmark_from_structure" => {
|
||||
let mut out = self.apply(&a, Edit::BookmarksFromStructure)?;
|
||||
let listing = self.bookmark_listing(&a)?;
|
||||
@@ -394,7 +424,7 @@ impl Automation {
|
||||
"page_replace" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?);
|
||||
let bytes = Arc::new(read_input(&path)?);
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let src_pages = match a.opt_ints("from_pages")? {
|
||||
Some(p) => one_based(&p)?,
|
||||
@@ -543,7 +573,7 @@ impl Automation {
|
||||
"delete" => ImageEdit::Delete,
|
||||
"replace" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
ImageEdit::Replace {
|
||||
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
bytes: Arc::new(bytes),
|
||||
@@ -773,7 +803,7 @@ impl Automation {
|
||||
"form_fill" => self.form_fill(&a)?,
|
||||
"form_set_image" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })?
|
||||
}
|
||||
"form_reset" => self.form_reset(&a)?,
|
||||
@@ -805,7 +835,27 @@ impl Automation {
|
||||
"page_add_image" => self.page_add_image(&a)?,
|
||||
"content_update" => self.content_update(&a)?,
|
||||
"content_delete" => self.content_delete(&a)?,
|
||||
"content_copy" => self.content_copy(&a)?,
|
||||
"doc_print" => self.doc_print(&a)?,
|
||||
"attachment_add" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = match a.opt_str("name")? {
|
||||
Some(n) => n.to_string(),
|
||||
None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
};
|
||||
let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string);
|
||||
let description = a.opt_str("description")?.map(str::to_string);
|
||||
// Only the document-level keys: an annotation attachment of the same name is not the new one.
|
||||
let before = document_attachment_keys(self.doc(&a)?);
|
||||
let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?;
|
||||
let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default();
|
||||
json!({ "name": key, "document": out })
|
||||
}
|
||||
"attachment_delete" => {
|
||||
let edit = Edit::DeleteAttachment { key: a.str("name")?.into() };
|
||||
self.apply(&a, edit)?
|
||||
}
|
||||
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
|
||||
"doc_audit_redactions" => self.doc_audit_redactions(&a)?,
|
||||
"fill_sign_add" => self.fill_sign_add(&a)?,
|
||||
@@ -1007,7 +1057,7 @@ impl Automation {
|
||||
|
||||
fn doc_open(&mut self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
|
||||
@@ -1185,7 +1235,7 @@ impl Automation {
|
||||
}
|
||||
|
||||
fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset};
|
||||
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset};
|
||||
let id = self.doc(a)?.id;
|
||||
let before = self.doc(a)?.bytes.len();
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
@@ -1193,6 +1243,10 @@ impl Automation {
|
||||
None => Preset::Standard.settings(),
|
||||
Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(),
|
||||
};
|
||||
if let Some(c) = a.opt_str("compatibility")? {
|
||||
settings.compatibility = Compatibility::from_name(c)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?;
|
||||
}
|
||||
let images = |key: &str, base: ImageSettings| -> Result<ImageSettings> {
|
||||
let Some(v) = a.get(key) else { return Ok(base) };
|
||||
let mut s = base;
|
||||
@@ -1226,6 +1280,8 @@ impl Automation {
|
||||
("discard_tags", &mut settings.discard_tags),
|
||||
("discard_print_settings", &mut settings.discard_print_settings),
|
||||
("flate_unencoded", &mut settings.flate_unencoded),
|
||||
("object_streams", &mut settings.object_streams),
|
||||
("lzw_to_flate", &mut settings.lzw_to_flate),
|
||||
("remove_invalid_links", &mut settings.remove_invalid_links),
|
||||
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
|
||||
] {
|
||||
@@ -1248,9 +1304,26 @@ impl Automation {
|
||||
};
|
||||
let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
fn valid_version(v: &[u8]) -> Option<&str> {
|
||||
if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None }
|
||||
}
|
||||
let catalog_version = (settings.compatibility == Compatibility::Retain)
|
||||
.then(|| {
|
||||
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
|
||||
let root = doc.root()?;
|
||||
doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
|
||||
})
|
||||
.flatten();
|
||||
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
|
||||
// Viewers read the later of the two (object streams raise the header past a lower catalog
|
||||
// entry). Both are "d.d", so text order is version order.
|
||||
let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max();
|
||||
let o = &r.optimize;
|
||||
Ok(json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"pdf_version": pdf_version,
|
||||
"object_streams": settings.effective_object_streams(),
|
||||
"bytes_before": before,
|
||||
"bytes_after": bytes.len(),
|
||||
"merged_images": o.merged_images,
|
||||
@@ -1264,6 +1337,7 @@ impl Automation {
|
||||
"alternate_images": o.alternate_images,
|
||||
"tags_removed": o.tags_removed,
|
||||
"streams_compressed": o.streams_compressed,
|
||||
"lzw_streams": o.lzw_streams,
|
||||
"invalid_links": o.invalid_links,
|
||||
"invalid_bookmarks": o.invalid_bookmarks,
|
||||
"unreferenced_dests": o.unreferenced_dests,
|
||||
@@ -1277,7 +1351,7 @@ impl Automation {
|
||||
fn mark_file(&self, a: &Args) -> Result<Option<pdfcraft_engine::MarkFile>> {
|
||||
let Some(p) = a.opt_str("file")? else { return Ok(None) };
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1;
|
||||
Ok(Some(pdfcraft_engine::MarkFile {
|
||||
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
@@ -1451,7 +1525,7 @@ impl Automation {
|
||||
let mut images = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes));
|
||||
}
|
||||
let name = if images.len() == 1 {
|
||||
@@ -1466,7 +1540,7 @@ impl Automation {
|
||||
(Some(t), _) => ("Text".to_string(), t.to_string()),
|
||||
(None, Some(p)) => {
|
||||
let path = self.resolve(p, false)?;
|
||||
let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let t = read_input(&path)?;
|
||||
(path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t))
|
||||
}
|
||||
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
|
||||
@@ -1641,7 +1715,7 @@ impl Automation {
|
||||
fn insert_file(&mut self, a: &Args) -> Result<Value> {
|
||||
let at = self.position(a, "at")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
|
||||
@@ -1684,7 +1758,7 @@ impl Automation {
|
||||
// Each file is read, converted and copied in turn, so a long list holds one file at a time.
|
||||
let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result<pdfcraft_engine::CombineSource, ToolError> {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?;
|
||||
let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
@@ -1701,7 +1775,7 @@ impl Automation {
|
||||
let mut out = Vec::new();
|
||||
for &p in paths {
|
||||
let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| {
|
||||
let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?;
|
||||
@@ -1805,7 +1879,7 @@ impl Automation {
|
||||
if let Some(g) = group {
|
||||
firsts.push((g, path.clone(), sources.len()));
|
||||
}
|
||||
let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?);
|
||||
let bytes = Arc::new(read_input(&path)?);
|
||||
// Images and text files are made into PDFs first (#569), as in the Combine files tab.
|
||||
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.session.as_pdf(&file_name, &bytes).map_err(failed)?
|
||||
@@ -2432,6 +2506,18 @@ impl Args<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments.
|
||||
fn document_attachment_keys(doc: &Document) -> Vec<String> {
|
||||
doc.info
|
||||
.attachments
|
||||
.iter()
|
||||
.filter_map(|x| match &x.source {
|
||||
pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()),
|
||||
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the
|
||||
/// next level down in `children`, and paths and pages are 1-based.
|
||||
fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec<Value> {
|
||||
@@ -2445,6 +2531,16 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::Pa
|
||||
}
|
||||
let path: Vec<usize> = row.path.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
|
||||
// Only what differs from the defaults (black, regular), as the PDF itself stores it.
|
||||
if let Some(color) = row.color {
|
||||
fields["color"] = json!(color);
|
||||
}
|
||||
if row.bold {
|
||||
fields["bold"] = json!(true);
|
||||
}
|
||||
if row.italic {
|
||||
fields["italic"] = json!(true);
|
||||
}
|
||||
if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) {
|
||||
fields["destination"] = destination;
|
||||
}
|
||||
@@ -2576,7 +2672,8 @@ fn info(d: &Document) -> Value {
|
||||
// geometry-taking tools (#129).
|
||||
"annotations": i.annotations.iter().map(|a| json!({
|
||||
"page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents,
|
||||
"modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": view_rect(i, a.page, a.rect),
|
||||
"modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to,
|
||||
"rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))),
|
||||
})).collect::<Vec<_>>(),
|
||||
"fields": i.fields.iter().map(|f| json!({
|
||||
"name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1),
|
||||
@@ -2598,7 +2695,8 @@ fn info(d: &Document) -> Value {
|
||||
})).collect::<Vec<_>>(),
|
||||
"preserve_rb": preserve_rb,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::RemoteGoTo { file, destination, new_window } => json!({
|
||||
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
|
||||
"action": if *launch { "Launch" } else { "GoToR" },
|
||||
"remote_document": file,
|
||||
"destination": match destination {
|
||||
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
|
||||
@@ -2659,7 +2757,7 @@ fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[u
|
||||
}
|
||||
};
|
||||
let config = RenderConfig { password, ..Default::default() };
|
||||
let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).min(8) };
|
||||
let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) };
|
||||
// Small jobs aren't worth a second parse of the document.
|
||||
if workers == 1 || pages.len() < 8 {
|
||||
let mut r = PageRenderer::new(bytes.clone(), config);
|
||||
@@ -2707,6 +2805,28 @@ fn unused(dir: &Path, stem: &str) -> PathBuf {
|
||||
(2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first)
|
||||
}
|
||||
|
||||
/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`).
|
||||
pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap.
|
||||
pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path)?;
|
||||
if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > MAX_INPUT {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn read_input(path: &Path) -> Result<Vec<u8>> {
|
||||
read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Write and sync a temporary file in the same directory, then rename over the target.
|
||||
/// This flushes staged contents; it does not promise parent-directory durability after a
|
||||
/// power loss. Filesystems that refuse sync cause the save to fail before publication.
|
||||
@@ -3264,4 +3384,36 @@ mod tests {
|
||||
assert!(a.session.get(id).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_file_reads_refuse_inputs_exceeding_512_mib() {
|
||||
let dir = staging_dir("capped-read");
|
||||
let huge = dir.join("huge.bin");
|
||||
let file = std::fs::File::create(&huge).unwrap();
|
||||
file.set_len(MAX_INPUT + 1).unwrap();
|
||||
drop(file);
|
||||
let huge_str = huge.to_string_lossy().into_owned();
|
||||
|
||||
let mut a = Automation::new();
|
||||
let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0;
|
||||
for (tool, args) in [
|
||||
("doc_open", json!({ "path": huge_str })),
|
||||
("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })),
|
||||
("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })),
|
||||
("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })),
|
||||
("doc_import_data", json!({ "doc": id, "path": huge_str })),
|
||||
("doc_create", json!({ "from": "text", "path": huge_str })),
|
||||
("doc_create", json!({ "from": "images", "paths": [huge_str] })),
|
||||
("doc_combine", json!({ "paths": [huge_str, huge_str] })),
|
||||
("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })),
|
||||
("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })),
|
||||
("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })),
|
||||
("sign_trust", json!({ "paths": [huge_str] })),
|
||||
("attachment_add", json!({ "doc": id, "path": huge_str })),
|
||||
] {
|
||||
let err = a.call(tool, &args).unwrap_err().to_string();
|
||||
assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ impl Automation {
|
||||
.iter()
|
||||
.find(|link| link.page == page && link.index == index)
|
||||
.ok_or_else(|| failed(format!("page {page_number} has no link {index_number} (see link_list)")))?;
|
||||
let LinkTarget::RemoteGoTo { file, destination, new_window } = &link.target else {
|
||||
let LinkTarget::RemoteGoTo { file, destination, new_window, .. } = &link.target else {
|
||||
return Err(bad("the selected link is not a supported local PDF GoToR link"));
|
||||
};
|
||||
(source.path.clone(), file.clone(), destination.clone(), *new_window)
|
||||
|
||||
@@ -25,6 +25,10 @@ const PARSE_ERROR: i64 = -32700;
|
||||
const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
|
||||
/// Longest JSON-RPC request line [`McpServer::serve`] buffers into memory (16 MiB).
|
||||
pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \
|
||||
(doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \
|
||||
@@ -83,18 +87,36 @@ impl McpServer {
|
||||
}
|
||||
|
||||
/// Serve until `input` reaches end of file.
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
for line in input.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match read_line_limited(&mut input, &mut buf, MAX_LINE_BYTES)? {
|
||||
LineRead::End => return Ok(()),
|
||||
LineRead::TooLong => {
|
||||
drain_line(&mut input)?;
|
||||
buf.clear();
|
||||
buf.shrink_to(64 * 1024);
|
||||
let reply = error(Value::Null, INVALID_REQUEST, &format!("request line exceeds {MAX_LINE_BYTES} bytes"));
|
||||
writeln!(output, "{reply}")?;
|
||||
output.flush()?;
|
||||
continue;
|
||||
}
|
||||
LineRead::Line => {}
|
||||
}
|
||||
if let Some(reply) = self.handle_line(&line) {
|
||||
let reply = match std::str::from_utf8(&buf) {
|
||||
Ok(line) if line.trim().is_empty() => None,
|
||||
Ok(line) => self.handle_line(line),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()),
|
||||
};
|
||||
if buf.capacity() > 64 * 1024 {
|
||||
buf.clear();
|
||||
buf.shrink_to(64 * 1024);
|
||||
}
|
||||
if let Some(reply) = reply {
|
||||
writeln!(output, "{reply}")?;
|
||||
output.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle one JSON-RPC message; returns the serialized reply, if one is due.
|
||||
@@ -116,7 +138,7 @@ impl McpServer {
|
||||
};
|
||||
let params = obj.get("params").cloned().unwrap_or(Value::Null);
|
||||
let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer
|
||||
let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) };
|
||||
let result = guarded_dispatch(method, || self.dispatch(method, ¶ms));
|
||||
Some(match result {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err((code, message)) => error(id, code, &message),
|
||||
@@ -323,11 +345,67 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> {
|
||||
Ok((name.to_owned(), inner))
|
||||
}
|
||||
|
||||
/// What [`read_line_limited`] found.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum LineRead {
|
||||
/// A line, now in the buffer (newline and a trailing `\r` stripped).
|
||||
Line,
|
||||
/// End of stream.
|
||||
End,
|
||||
/// The line is longer than the limit; at most `max + 1` bytes of it were read.
|
||||
TooLong,
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf`.
|
||||
fn read_line_limited(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> std::io::Result<LineRead> {
|
||||
use std::io::Read as _;
|
||||
buf.clear();
|
||||
// `take` bounds what is buffered for one line: never more than `max + 1` bytes.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
if reader.take(limit).read_until(b'\n', buf)? == 0 {
|
||||
return Ok(LineRead::End);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(LineRead::Line);
|
||||
}
|
||||
// No newline: either the limit was passed, or this is a last line before end of stream.
|
||||
Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line })
|
||||
}
|
||||
|
||||
/// Discard the rest of an overlong line up to `\n` or end of stream without buffering it.
|
||||
fn drain_line(reader: &mut impl BufRead) -> std::io::Result<()> {
|
||||
loop {
|
||||
let chunk = reader.fill_buf()?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(pos) = chunk.iter().position(|&b| b == b'\n') {
|
||||
reader.consume(pos + 1);
|
||||
return Ok(());
|
||||
}
|
||||
let len = chunk.len();
|
||||
reader.consume(len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-resort boundary shared by every tools/call route, including compact dispatch.
|
||||
fn guarded_tool(f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Value, (i64, String)> {
|
||||
pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}"))))
|
||||
}
|
||||
|
||||
/// Last-resort boundary for every JSON-RPC method: tool calls report panics in the tool result
|
||||
/// (`isError: true`), while resource and protocol methods report a JSON-RPC internal error.
|
||||
fn guarded_dispatch(method: &str, f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Value, (i64, String)> {
|
||||
if method == "tools/call" {
|
||||
return guarded_tool(f);
|
||||
}
|
||||
pdfcraft_engine::guard(f).unwrap_or_else(|message| Err((INTERNAL_ERROR, format!("internal error: {message}"))))
|
||||
}
|
||||
|
||||
fn tool_content(name: &str, content: Vec<Content>) -> Value {
|
||||
let failed = matches!(name, "command_batch" | "doc_batch")
|
||||
&& content.iter().any(|c| match c {
|
||||
@@ -509,7 +587,12 @@ fn call_result(content: Vec<Content>) -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Resource, first_sentence, parse_uri};
|
||||
use std::io::{BufReader, Read as _};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{McpServer, Resource, first_sentence, parse_uri};
|
||||
use crate::Automation;
|
||||
|
||||
#[test]
|
||||
fn conventions_panic_is_a_tool_error() {
|
||||
@@ -520,6 +603,59 @@ mod tests {
|
||||
assert_eq!(next["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_dispatch_catches_resource_and_protocol_panics() {
|
||||
for method in ["resources/read", "resources/list", "resources/templates/list", "initialize"] {
|
||||
let (code, msg) = super::guarded_dispatch(method, || panic!("synthetic {method} failure")).unwrap_err();
|
||||
assert_eq!(code, -32603, "{method}");
|
||||
assert!(msg.contains(&format!("synthetic {method} failure")), "{msg}");
|
||||
}
|
||||
let ok = super::guarded_dispatch("resources/list", || Ok(json!({"resources": []}))).unwrap();
|
||||
assert_eq!(ok["resources"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_line_limited_and_drain_line_respect_boundaries() {
|
||||
use super::{LineRead, drain_line, read_line_limited};
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End);
|
||||
|
||||
let mut overlong: &[u8] = b"0123456789abcdef\nafter\n";
|
||||
assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::TooLong);
|
||||
assert_eq!(buf.len(), 11, "at most max + 1 bytes are buffered");
|
||||
drain_line(&mut overlong).unwrap();
|
||||
assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"after", "the next line after an overlong line is read cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_answers_non_utf8_and_overlong_lines_and_keeps_serving() {
|
||||
let init = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } }
|
||||
})
|
||||
.to_string();
|
||||
let tail = format!("\n{init}\n");
|
||||
let stream = (&b"\xff\n"[..]).chain(std::io::repeat(b'a').take(64 * 1024 * 1024)).chain(tail.as_bytes());
|
||||
let mut out = Vec::new();
|
||||
McpServer::new(Automation::new()).serve(BufReader::new(stream), &mut out).unwrap();
|
||||
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 3, "{replies:?}");
|
||||
assert_eq!(replies[0]["error"]["code"], -32700);
|
||||
assert_eq!(replies[1]["error"]["code"], -32600);
|
||||
assert_eq!(replies[2]["id"], 1);
|
||||
assert_eq!(replies[2]["result"]["serverInfo"]["name"], "pdfcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_sentence_skips_abbreviations() {
|
||||
assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages.");
|
||||
|
||||
@@ -22,11 +22,72 @@ fn duplex(value: &str, cut_stack: bool) -> Result<Option<spool::Duplex>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet
|
||||
/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`).
|
||||
fn job_duplex(value: &str, layout: &Layout) -> Result<Option<spool::Duplex>> {
|
||||
let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?;
|
||||
Ok(match layout {
|
||||
Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)),
|
||||
_ => chosen,
|
||||
})
|
||||
}
|
||||
|
||||
/// An invalid-arguments error saying `m`.
|
||||
fn bad(m: impl Into<String>) -> ToolError {
|
||||
ToolError::InvalidArgs(m.into())
|
||||
}
|
||||
|
||||
/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS`
|
||||
/// index and size; US Letter when none is given.
|
||||
fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
|
||||
let mut papers = PAPERS.iter().enumerate();
|
||||
let found = match name {
|
||||
None => papers.next(),
|
||||
Some(p) => papers.find(|(_, (n, _))| {
|
||||
n.eq_ignore_ascii_case(p)
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
|
||||
}),
|
||||
};
|
||||
found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"unknown paper {:?}; choose one of {}",
|
||||
name.unwrap_or_default(),
|
||||
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
|
||||
/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default
|
||||
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752).
|
||||
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: usize) -> Result<spool::Job> {
|
||||
let options = match a.get("options") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Object(m)) => m
|
||||
.iter()
|
||||
.map(|(k, v)| match v.as_str() {
|
||||
Some(v) => Ok((k.clone(), v.to_string())),
|
||||
None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")),
|
||||
};
|
||||
Ok(spool::Job {
|
||||
printer: if printer == "default" { None } else { Some(printer.to_string()) },
|
||||
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
|
||||
collate: a.opt_bool("collate")?.unwrap_or(true),
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
title,
|
||||
options: options.into_iter().chain(print::media_option(paper)).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// `printers`: the system's printers, the default marked, and each one's two-sided support
|
||||
/// where the spooler reports it.
|
||||
pub(crate) fn printers(&self) -> Result<Value> {
|
||||
let list: Vec<Value> = spool::printers()
|
||||
.into_iter()
|
||||
@@ -53,6 +114,8 @@ impl Automation {
|
||||
Ok(json!({ "printer": printer, "count": list.len(), "options": list }))
|
||||
}
|
||||
|
||||
/// `doc_print`: lay the document out with the Print dialog's options, then save the
|
||||
/// print-ready PDF to `path` or send it to `printer` with the chosen paper.
|
||||
pub(crate) fn doc_print(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, count) = (doc.id, doc.info.pages.len());
|
||||
@@ -108,7 +171,7 @@ impl Automation {
|
||||
},
|
||||
l => return Err(bad(format!("unknown layout {l:?}"))),
|
||||
};
|
||||
let duplex = duplex(a.opt_str("duplex")?.unwrap_or("off"), matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?;
|
||||
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
|
||||
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
|
||||
return Err(bad("cut-stack order needs layout multiple"));
|
||||
}
|
||||
@@ -125,20 +188,7 @@ impl Automation {
|
||||
"form-fields-only" => Content::FormFieldsOnly,
|
||||
c => return Err(bad(format!("unknown comments_forms {c:?}"))),
|
||||
};
|
||||
let paper = match a.opt_str("paper")? {
|
||||
None => PAPERS[0].1,
|
||||
Some(p) => PAPERS
|
||||
.iter()
|
||||
.find(|(n, _)| {
|
||||
n.eq_ignore_ascii_case(p)
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
|
||||
})
|
||||
.map(|x| x.1)
|
||||
.ok_or_else(|| {
|
||||
bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")))
|
||||
})?,
|
||||
};
|
||||
let (paper_index, paper) = paper_named(a.opt_str("paper")?)?;
|
||||
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
|
||||
// `grayscale` is also asked for.
|
||||
let raster = if a.opt_bool("as_image")?.unwrap_or(false) {
|
||||
@@ -162,27 +212,7 @@ impl Automation {
|
||||
out["path"] = json!(path.to_string_lossy());
|
||||
}
|
||||
(None, Some(printer)) => {
|
||||
let name = self.doc(a)?.name.clone();
|
||||
let job = spool::Job {
|
||||
printer: if printer == "default" { None } else { Some(printer.to_string()) },
|
||||
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
|
||||
collate: a.opt_bool("collate")?.unwrap_or(true),
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
title: name,
|
||||
options: match a.get("options") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Object(m)) => m
|
||||
.iter()
|
||||
.map(|(k, v)| match v.as_str() {
|
||||
Some(v) => Ok((k.clone(), v.to_string())),
|
||||
None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")),
|
||||
},
|
||||
};
|
||||
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?;
|
||||
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
@@ -195,6 +225,19 @@ impl Automation {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error.
|
||||
#[test]
|
||||
fn a_booklet_decides_two_sided_itself() {
|
||||
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left };
|
||||
for asked in ["printer", "off", "long-edge", "short-edge"] {
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
}
|
||||
assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err());
|
||||
assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it");
|
||||
}
|
||||
|
||||
/// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks
|
||||
/// for single-sided sheets, and refuses an explicit edge.
|
||||
#[test]
|
||||
@@ -207,4 +250,30 @@ mod tests {
|
||||
assert!(duplex("long-edge", true).is_err());
|
||||
assert!(duplex("sideways", false).is_err());
|
||||
}
|
||||
|
||||
/// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for
|
||||
/// the one the sheets were laid out on (A4 under Letter sheets on the report's queue).
|
||||
#[test]
|
||||
fn printing_to_a_printer_feeds_the_laid_out_paper() {
|
||||
let job = |args: Value, paper_name: Option<&str>| {
|
||||
let (index, _) = paper_named(paper_name).unwrap();
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap()
|
||||
};
|
||||
let media = |m: &str| ("media".to_string(), m.to_string());
|
||||
// US Letter when no paper is asked for, after the driver's own options.
|
||||
let letter = job(json!({ "options": { "InputSlot": "Tray3" } }), None);
|
||||
assert_eq!(letter.options, [("InputSlot".to_string(), "Tray3".to_string()), media("Letter")]);
|
||||
assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf"));
|
||||
// Every paper, by its full name, goes out as its own media name.
|
||||
for (i, (name, size)) in PAPERS.iter().enumerate() {
|
||||
assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}");
|
||||
assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::<Vec<_>>(), "{name}");
|
||||
}
|
||||
// Short forms find the same paper.
|
||||
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
|
||||
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
|
||||
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ impl Automation {
|
||||
return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows")));
|
||||
}
|
||||
let path = self.resolve(a.str("id")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e {
|
||||
sign::SignError::WrongPassword => bad("the digital ID password is incorrect"),
|
||||
e => failed(e),
|
||||
@@ -320,7 +320,7 @@ impl Automation {
|
||||
};
|
||||
for p in paths {
|
||||
let path = self.resolve(&p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") {
|
||||
let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?;
|
||||
|
||||
@@ -129,6 +129,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"dpi": {"type":"number","minimum":72,"maximum":600},
|
||||
"language": {"type":"string"}, "skip_text_pages": {"type":"boolean"}
|
||||
}), &["paths","out_dir","steps"])),
|
||||
t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.")
|
||||
.cmd("edit.attach")
|
||||
.with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])),
|
||||
t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])),
|
||||
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
|
||||
.cmd("file.open")
|
||||
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
|
||||
@@ -278,7 +284,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"bookmark_list",
|
||||
"List bookmarks",
|
||||
"The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.",
|
||||
"The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, its colour ([r, g, b], 0-1) and bold or italic style when it has them, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(
|
||||
@@ -317,6 +323,21 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
)),
|
||||
t("bookmark_set_page", "Set a bookmark's page", "Point a bookmark at another page. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "page": { "type": "integer", "minimum": 1 } }), &["doc", "path", "page"])),
|
||||
t(
|
||||
"bookmark_set_style",
|
||||
"Set a bookmark's style and colour",
|
||||
"Colour a bookmark's title and make it bold or italic (ISO 32000-2 §12.3.3: /C and /F), as bookmark_list reports them. Give any of color ([r, g, b], each 0-1; [0, 0, 0] is the default black), bold and italic; what is left out stays as it is. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"path": path("The bookmark"),
|
||||
"color": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 3, "maxItems": 3 },
|
||||
"bold": { "type": "boolean" },
|
||||
"italic": { "type": "boolean" }
|
||||
}),
|
||||
&["doc", "path"],
|
||||
)),
|
||||
t(
|
||||
"bookmark_from_structure",
|
||||
"New bookmarks from structure",
|
||||
@@ -349,7 +370,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"form_add_field",
|
||||
"Prepare form: add a field",
|
||||
"Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1). Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.",
|
||||
"Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1); a dotted name such as MASTER.first makes the field first under the parent field MASTER. Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -389,7 +410,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "page", "rect", "rows"],
|
||||
)),
|
||||
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1) or an image file, centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt. name labels it (default: the file name). Undoable.")
|
||||
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.")
|
||||
.cmd("comment.stamp")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -397,18 +418,19 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"path": { "type": "string" },
|
||||
"at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"file_page": { "type": "integer", "minimum": 1 },
|
||||
"name": { "type": "string" },
|
||||
"author": { "type": "string" },
|
||||
}),
|
||||
&["doc", "page", "path", "at"],
|
||||
&["doc", "page", "path"],
|
||||
)),
|
||||
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
|
||||
t(
|
||||
"form_set_props",
|
||||
"Field properties",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -527,7 +549,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_print",
|
||||
"Print",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -566,7 +588,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"page_add_text",
|
||||
"Add text to a page",
|
||||
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
|
||||
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
|
||||
)
|
||||
.cmd("edit.text")
|
||||
.with(schema(
|
||||
@@ -578,6 +600,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
}),
|
||||
&["doc", "page", "text"],
|
||||
)),
|
||||
@@ -591,7 +614,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }),
|
||||
&["doc", "page", "path"],
|
||||
)),
|
||||
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
|
||||
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
|
||||
@@ -599,12 +622,27 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 },
|
||||
"image": { "type": "string", "description": "Replace the picture with this file." },
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
)),
|
||||
t(
|
||||
"content_copy",
|
||||
"Copy and paste added content",
|
||||
"Copy an added item (page, index from content_list) and paste it, as Edit ▸ Copy and Paste do: onto to_page of to_doc (by default its own page and document) as it is: a text box's text, font, size, colour, alignment and box; an image's picture, box, turns, flips and crop (within its document the same image object is drawn again; into another one the picture goes as its JPEG or JPEG 2000 file, or else a PNG, without a colour profile and with CMYK colours turned to RGB). It goes at at [x, y] (its top-left, points from the top-left of the page), else where it was, 18 points down and to the right past each copy of it already there; kept on the page. Refused when the source document doesn't allow copying. Returns the new item's page, index and rect. Undoable in the target document.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
|
||||
"to_doc": { "type": "integer", "minimum": 1, "description": "The document to paste into (default doc)." },
|
||||
"to_page": { "type": "integer", "minimum": 1, "description": "The page to paste onto (default page)." },
|
||||
"at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
)),
|
||||
t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
|
||||
@@ -693,7 +731,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"stamp": { "type": "string" },
|
||||
"dynamic": { "type": "boolean" },
|
||||
"strokes": { "type": "array", "items": { "type": "array", "items": point() } },
|
||||
"icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph; attachment: PushPin, Paperclip, Graph, Tag." },
|
||||
"icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." },
|
||||
"color": color(),
|
||||
"fill": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
@@ -1053,7 +1091,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
|
||||
.cmd("a11y.alt_text")
|
||||
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
|
||||
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.")
|
||||
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text, language, a table's summary and a header cell's scope, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.")
|
||||
.ro()
|
||||
.cmd("a11y.tags")
|
||||
.with(schema(
|
||||
@@ -1071,11 +1109,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("a11y.tags")
|
||||
.with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])),
|
||||
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
|
||||
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language, summary (Table tags: the table's Summary attribute), scope (TH tags: Row, Column or Both). Summary and scope are written in the tag's Table attribute object (/A, /O /Table); the role map decides a tag's type. Empty or omitted value clears text properties, summary and scope; type requires a nonempty PDF name. Undoable.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
|
||||
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] },
|
||||
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
|
||||
"value": { "type": "string" },
|
||||
}), &["doc", "object", "generation", "property"])),
|
||||
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.")
|
||||
@@ -1094,7 +1132,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
|
||||
.cmd("form.merge_data")
|
||||
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.")
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
@@ -1184,7 +1222,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Returns, per file, the output path, word count and skipped pages, or the error.")
|
||||
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Two inputs that would write the same output file are refused before anything is read or written. Returns, per file, the output path, word count and skipped pages, or the error.")
|
||||
.cmd("ocr.recognize_batch")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1359,13 +1397,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
|
||||
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
.cmd("optimize.advanced")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"path": { "type": "string" },
|
||||
"preset": { "type": "string", "enum": ["standard", "mobile"] },
|
||||
"compatibility": { "type": "string", "enum": ["retain", "1.4", "1.5", "1.6", "1.7", "2.0"] },
|
||||
"merge_fragments": { "type": "boolean" },
|
||||
"color": { "type": "object" },
|
||||
"gray": { "type": "object" },
|
||||
@@ -1374,6 +1413,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"discard_tags": { "type": "boolean" },
|
||||
"discard_print_settings": { "type": "boolean" },
|
||||
"flate_unencoded": { "type": "boolean" },
|
||||
"object_streams": { "type": "boolean" },
|
||||
"lzw_to_flate": { "type": "boolean" },
|
||||
"remove_invalid_links": { "type": "boolean" },
|
||||
"remove_unreferenced_dests": { "type": "boolean" },
|
||||
"discard": { "type": "array", "items": { "type": "string" } },
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `attachment_add` reports the name-tree key of the file it added, even when a file attached to
|
||||
//! a page as a comment already has the same name.
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use pdfcraft_engine::{AttachIcon, Edit, NewAnnotation, Session, Shape, Style};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(value) => value,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_add_reports_its_own_key_when_a_page_comment_has_the_same_file_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open("a.pdf", None, bytes, None).unwrap();
|
||||
let shape = Shape::Attachment { at: [20.0, 300.0], icon: AttachIcon::Paperclip, file: "notes.txt".into(), data: b"on the page".to_vec() };
|
||||
session
|
||||
.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style: Style::default(), contents: String::new(), author: "Ada".into() }))
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("a.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap();
|
||||
std::fs::write(dir.path().join("notes.txt"), b"for the document").unwrap();
|
||||
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let doc = tool(&mut auto, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let added = tool(&mut auto, "attachment_add", json!({ "doc": doc, "path": "notes.txt" }));
|
||||
assert_eq!(added["name"], "notes.txt", "{added}");
|
||||
let again = tool(&mut auto, "attachment_add", json!({ "doc": doc, "path": "notes.txt" }));
|
||||
assert_eq!(again["name"], "notes.txt (2)", "{again}");
|
||||
}
|
||||
@@ -2331,6 +2331,51 @@ fn forms_through_tools() {
|
||||
assert_ne!(v, "Filled by an agent");
|
||||
}
|
||||
|
||||
/// A PDF of the given objects (numbered from 1).
|
||||
fn pdf_of(objs: &[&str]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// #4018: `form_fill` warns about a value its box cuts off at the field's fixed font size (the
|
||||
/// value is saved whole), and not about one that fits.
|
||||
#[test]
|
||||
fn form_fill_warns_when_a_value_does_not_fit() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>",
|
||||
"<< /Fields [5 0 R 6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (narrow) /Rect [10 250 150 268] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (wide) /Rect [10 200 310 218] /P 3 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
];
|
||||
let dir = workdir("form-overflow");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
let long = "Doe, Jane (represented by John Doe)";
|
||||
let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": long, "wide": long } }));
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
assert_eq!(warnings.len(), 1, "{r}");
|
||||
assert!(warnings[0].starts_with("form field \"narrow\" (page 1): the value does not fit"), "{}", warnings[0]);
|
||||
let after = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert_eq!(after["fields"][0]["value"], long, "saved whole");
|
||||
let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": "Ada" } }));
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and
|
||||
/// resets through the tools, and the leniency shows up as a repair note.
|
||||
#[test]
|
||||
@@ -2346,19 +2391,7 @@ fn orphan_form_fields_through_tools() {
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
pdf_of(&objs)
|
||||
}
|
||||
let dir = workdir("orphan-forms");
|
||||
std::fs::write(dir.join("orphan.pdf"), orphan_form()).unwrap();
|
||||
@@ -2730,6 +2763,66 @@ trailer << /Root 1 0 R >>
|
||||
assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
/// A table's summary and a header cell's scope are set through `accessibility_set_tag_property`
|
||||
/// (#4103, #4104): the summary clears the table-summary finding, both are listed with the tags,
|
||||
/// undone, and saved.
|
||||
#[test]
|
||||
fn table_summary_and_header_scope_through_automation() {
|
||||
let dir = workdir("a11y-table");
|
||||
let content = "/TH << /MCID 0 >> BDC 10 150 40 20 re f EMC /TD << /MCID 1 >> BDC 60 150 40 20 re f EMC";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 11 0 R >> endobj
|
||||
6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj
|
||||
7 0 obj << /S /Table /P 6 0 R /K [8 0 R] >> endobj
|
||||
8 0 obj << /S /TR /P 7 0 R /K [9 0 R 10 0 R] >> endobj
|
||||
9 0 obj << /S /TH /P 8 0 R /Pg 3 0 R /K 0 >> endobj
|
||||
10 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 1 >> endobj
|
||||
11 0 obj << /Nums [0 [9 0 R 10 0 R]] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
);
|
||||
std::fs::write(dir.join("table.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let fd = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
|
||||
let summary_status = |a: &mut Automation| {
|
||||
let r = ok(a, "accessibility_check", json!({ "doc": fd, "rules": ["table-summary"] }));
|
||||
r["results"].as_array().unwrap().iter().find(|x| x["rule"] == "table-summary").unwrap()["status"].as_str().unwrap().to_owned()
|
||||
};
|
||||
assert_eq!(summary_status(&mut a), "failed");
|
||||
let args =
|
||||
|object: u32, property: &str, value: &str| json!({ "doc": fd, "object": object, "generation": 0, "property": property, "value": value });
|
||||
let tags = ok(&mut a, "accessibility_set_tag_property", args(7, "summary", "Opening hours by day"));
|
||||
let tag = |tags: &Value, object: u64| tags["tags"].as_array().unwrap().iter().find(|t| t["object"] == object).unwrap().clone();
|
||||
assert_eq!(tag(&tags, 7)["summary"], "Opening hours by day");
|
||||
assert_eq!(summary_status(&mut a), "passed");
|
||||
let tags = ok(&mut a, "accessibility_set_tag_property", args(9, "scope", "Column"));
|
||||
assert_eq!(tag(&tags, 9)["scope"], "Column");
|
||||
// A scope is Row, Column or Both; only a table has a summary and only a header cell a scope.
|
||||
for (object, property, value) in [(9, "scope", "column"), (10, "scope", "Row"), (9, "summary", "No")] {
|
||||
assert!(
|
||||
matches!(err(&mut a, "accessibility_set_tag_property", args(object, property, value)), ToolError::Failed(_)),
|
||||
"{property} {value} on {object}"
|
||||
);
|
||||
}
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties");
|
||||
assert_eq!(tag(&ok(&mut a, "accessibility_tags", json!({ "doc": fd })), 9)["scope"], Value::Null);
|
||||
ok(&mut a, "edit_redo", json!({ "doc": fd }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": fd, "path": "tagged-table.pdf" }));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("tagged-table.pdf")).unwrap())).unwrap();
|
||||
// Saving renumbers the objects: the tags are found by type.
|
||||
let saved = pdfcraft_engine::a11y::tags(&saved);
|
||||
assert_eq!(saved.iter().find(|t| t.kind == "Table").and_then(|t| t.summary.as_deref()), Some("Opening hours by day"));
|
||||
assert_eq!(saved.iter().find(|t| t.kind == "TH").and_then(|t| t.scope.as_deref()), Some("Column"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() {
|
||||
let dir = workdir("a11y-artifact");
|
||||
@@ -2910,6 +3003,43 @@ fn editing_existing_text_through_tools() {
|
||||
assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() {
|
||||
if pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
eprintln!("skipping: built without craft-fonts");
|
||||
return;
|
||||
}
|
||||
let dir = workdir("styled-fallback");
|
||||
std::fs::write(
|
||||
dir.join("mixed.pdf"),
|
||||
helvetica_page(
|
||||
"BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "mixed.pdf"}))["doc"].as_u64().unwrap();
|
||||
let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field.";
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":replacement,"width":520}));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap();
|
||||
let page = pdfcraft_model::pages(&saved).swap_remove(0);
|
||||
let resources = saved.dict(page.dict.get(b"Resources").unwrap()).unwrap();
|
||||
let fonts = saved.dict(resources.get(b"Font").unwrap()).unwrap();
|
||||
let fallback_count = fonts.iter().filter(|(name, _)| name.starts_with(b"PCJp")).count();
|
||||
assert_eq!(fallback_count, 1, "one shared fallback font for the new word");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert!(!page_text(&mut a, doc)[0].contains("шалом"));
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
assert!(page_text(&mut a, doc)[0].contains("шалом"));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
let lines = ok(&mut a, "text_lines", json!({"doc":reopened,"page":1}));
|
||||
let runs = lines["lines"].as_array().unwrap();
|
||||
assert!(runs.iter().any(|l| l["text"].as_str().is_some_and(|t| t.starts_with("The quick")) && l["font"] == "Helvetica"), "{lines}");
|
||||
assert_eq!(runs.iter().filter(|l| l["text"] == "шалом").count(), 1, "{lines}");
|
||||
assert!(page_text(&mut a, reopened)[0].contains("шалом"));
|
||||
}
|
||||
|
||||
/// #2593: a paragraph whose own font cannot show a character is written from PdfCraft's fallback
|
||||
/// faces. The Cyrillic the craft-fonts faces lack (Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ) comes from the
|
||||
/// bundled Inter face, so the edit is not refused even without `CRAFT_FONTS_DIR`, and the words
|
||||
@@ -3882,6 +4012,28 @@ fn redacting_through_tools() {
|
||||
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Mark, apply and save remove the complete international number while retaining labels and controls.
|
||||
fn redacting_italian_international_slash_numbers_through_tools() {
|
||||
let dir = workdir("redact-italian-international-slash");
|
||||
std::fs::write(dir.join("phones.txt"), "A tel. +39 0426/123456\nB tel. 0426/123456\nC protocol 2024/123456\nD on 01/10/2026").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "phones.txt" }))["doc"].as_u64().unwrap();
|
||||
let marked = ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" }));
|
||||
assert_eq!(marked["marked"], 2, "both complete phone numbers need marks");
|
||||
assert!(page_text(&mut a, doc)[0].contains("+39 0426/123456"), "marks alone remove nothing");
|
||||
assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": doc }))["applied"], 2);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "redacted.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap();
|
||||
let text = &page_text(&mut a, reopened)[0];
|
||||
assert!(!text.contains("+39") && !text.contains("0426"), "{text}");
|
||||
for label in ["A tel.", "B tel."] {
|
||||
assert!(text.lines().any(|line| line.trim() == label), "only the surrounding label remains: {text}");
|
||||
}
|
||||
assert!(text.contains("protocol 2024/123456") && text.contains("on 01/10/2026"), "unrelated digit runs remain: {text}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// One page with the text "Secret 1234" and a Redact annotation that has neither a usable
|
||||
/// QuadPoints array nor a Rect, so there is no area to redact.
|
||||
fn redact_pdf_with_unreadable_mark() -> Vec<u8> {
|
||||
@@ -4211,6 +4363,91 @@ fn adding_content_through_tools() {
|
||||
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy and Paste headlessly (#2636): an added text box or image, as it is, onto its own
|
||||
/// page beside it, onto a point, and into another document.
|
||||
#[test]
|
||||
fn copying_added_content_through_tools() {
|
||||
let dir = workdir("content-copy");
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 40, 20);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[90u8; 2400]).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("logo.png"), png).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let other = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(
|
||||
&mut a,
|
||||
"page_add_text",
|
||||
json!({ "doc": doc, "page": 1, "text": "Approved", "at": [20, 20], "width": 100, "size": 18, "bold": true, "font": "times", "color": "red", "align": "center" }),
|
||||
);
|
||||
ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] }));
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true }));
|
||||
let original = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
|
||||
// Beside itself on its own page: 18 points down and to the right, with the style content_list
|
||||
// shows (the engine test checks colour, alignment and the image transforms).
|
||||
let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 1 }));
|
||||
assert_eq!((r["page"].as_u64(), r["index"].as_u64()), (Some(1), Some(3)));
|
||||
let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
|
||||
let (from, to) = (&original[0], &items[2]);
|
||||
for key in ["type", "text", "font", "size", "bold", "italic"] {
|
||||
assert_eq!(from[key], to[key], "{key}");
|
||||
}
|
||||
let shifted: Vec<f64> = from["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 18.0).collect();
|
||||
assert_eq!(to["rect"], json!(shifted));
|
||||
assert_eq!(r["rect"], to["rect"]);
|
||||
// An image onto another page at a point, and into another document where it was.
|
||||
let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_page": 2, "at": [10, 30] }));
|
||||
assert_eq!((r["page"].as_u64(), r["index"].as_u64()), (Some(2), Some(1)));
|
||||
assert_eq!(r["rect"][0].as_f64().zip(r["rect"][1].as_f64()), Some((10.0, 30.0)), "its top-left at the point");
|
||||
let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_doc": other }));
|
||||
assert_eq!(r["rect"], original[1]["rect"], "in the same place");
|
||||
let theirs = ok(&mut a, "content_list", json!({ "doc": other }));
|
||||
assert_eq!((theirs["count"].as_u64(), theirs["items"][0]["type"].as_str()), (Some(1), Some("image")));
|
||||
// Copied there again: it steps past the first copy, whose picture that document stores itself.
|
||||
let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_doc": other }));
|
||||
let stepped: Vec<f64> = original[1]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 18.0).collect();
|
||||
assert_eq!(r["rect"], json!(stepped), "beside the first copy, not on it");
|
||||
// Each paste is one undo step in the document it went into.
|
||||
ok(&mut a, "edit_undo", json!({ "doc": other }));
|
||||
ok(&mut a, "edit_undo", json!({ "doc": other }));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": other }))["count"], 0);
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 4);
|
||||
// Out of range: a readable failure.
|
||||
assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 9 })), Err(ToolError::Failed(_))));
|
||||
assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "to_page": 9 })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "to_doc": 999 })), Err(ToolError::Failed(_))));
|
||||
for at in [json!([1]), json!([10, "x", 30]), json!("10, 30")] {
|
||||
assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "at": at })), Err(ToolError::InvalidArgs(_))), "{at}");
|
||||
}
|
||||
// A second copy onto its own page steps past the first.
|
||||
let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 1 }));
|
||||
let twice: Vec<f64> = from["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 36.0).collect();
|
||||
assert_eq!(r["rect"], json!(twice));
|
||||
|
||||
// A document that doesn't allow copying keeps its content in, even with changes allowed.
|
||||
let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
cos.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: -1 ^ 0b1_0000,
|
||||
encrypt_metadata: true,
|
||||
seed: [3; 32],
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("no-copy.pdf"), pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()).unwrap();
|
||||
let locked = ok(&mut a, "doc_open", json!({ "path": "no-copy.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_text", json!({ "doc": locked, "page": 1, "text": "Secret", "at": [20, 20], "width": 100 }));
|
||||
for to in [locked, other] {
|
||||
let refused = a.call("content_copy", &json!({ "doc": locked, "page": 1, "index": 1, "to_doc": to }));
|
||||
assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("copying")), "{refused:?}");
|
||||
}
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": locked }))["count"], 1);
|
||||
}
|
||||
|
||||
/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable
|
||||
/// failure for `content_update` and `content_delete`, not a panic.
|
||||
#[test]
|
||||
@@ -4229,6 +4466,24 @@ fn content_index_out_of_range_is_an_error_not_a_panic() {
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1);
|
||||
}
|
||||
|
||||
/// Character spacing on added text (#4245): set when adding or restyling, listed back, and refused
|
||||
/// out of range or on images.
|
||||
#[test]
|
||||
fn added_text_takes_character_spacing() {
|
||||
let dir = workdir("content-char-spacing");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Spaced", "at": [20, 20], "char_spacing": 2 }));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], 2.0);
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": -0.5 }));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5);
|
||||
for bad in [json!(1000), json!(-1000), json!("wide")] {
|
||||
let args = json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": bad });
|
||||
assert!(matches!(a.call("content_update", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||||
}
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5);
|
||||
}
|
||||
|
||||
fn added_rotation_image(dir: &Path) {
|
||||
let mut png = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 1);
|
||||
@@ -4642,6 +4897,64 @@ fn stamps_through_tools() {
|
||||
assert!(matches!(a.call("stamp_custom", &json!({ "doc": doc, "page": 1, "path": "nope.png", "at": [1, 1] })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
/// #4077: a PDF page placed at a given width (a text column, 483 pt) through `rect`, fitted
|
||||
/// inside it keeping its shape and centred, and still vector (its text is text).
|
||||
#[test]
|
||||
fn custom_stamps_fit_a_pdf_page_inside_a_rect() {
|
||||
let dir = workdir("stamp_rect");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 595, "height": 842 }))["doc"].as_u64().unwrap();
|
||||
let rect_of = |a: &mut Automation, i: usize| -> Vec<f64> {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc }));
|
||||
list["comments"][i]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
|
||||
};
|
||||
// Without rect, the page keeps its natural size, at most 200 pt: 200 × 300 is 133⅓ × 200.
|
||||
ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "at": [100, 150] }));
|
||||
let natural = rect_of(&mut a, 0);
|
||||
assert!((natural[2] - natural[0] - 400.0 / 3.0).abs() < 0.01 && (natural[3] - natural[1] - 200.0).abs() < 0.01, "{natural:?}");
|
||||
// 483 × 750: the width limits the 200 × 300 page to 483 × 724.5, centred top to bottom.
|
||||
ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "rect": [56, 50, 539, 800] }));
|
||||
assert_eq!(rect_of(&mut a, 1), [56.0, 62.75, 539.0, 787.25]);
|
||||
assert!(page_text(&mut a, doc)[0].contains("Page 2"), "the stamped page's text is still text");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add stamp");
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1);
|
||||
|
||||
for (args, why) in [
|
||||
(json!({ "rect": [10, 10, 10, 50] }), "zero width"),
|
||||
(json!({ "rect": [10, 10, 50, 10] }), "zero height"),
|
||||
(json!({ "rect": [0, 0, 1e-10, 100] }), "thinner than a point"),
|
||||
(json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"),
|
||||
(json!({ "rect": [0, 0, 100, f64::NAN] }), "not a number"),
|
||||
(json!({ "rect": [0, 0, 100] }), "three numbers"),
|
||||
(json!({ "rect": [0, 0, 100, 100], "at": [10, 10] }), "both a box and a point"),
|
||||
(json!({}), "neither a box nor a point"),
|
||||
] {
|
||||
let mut call = json!({ "doc": doc, "page": 1, "path": "b.pdf" });
|
||||
call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone());
|
||||
assert!(matches!(a.call("stamp_custom", &call), Err(ToolError::InvalidArgs(_))), "{why}");
|
||||
}
|
||||
let past_the_end = json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 9, "rect": [0, 0, 100, 100] });
|
||||
assert!(matches!(a.call("stamp_custom", &past_the_end), Err(ToolError::Failed(_))));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1, "a refused stamp adds nothing");
|
||||
|
||||
// On a quarter-turned page the picture is fitted as displayed: an 80 × 40 picture in a
|
||||
// 100 × 100 box is 100 × 50 on screen, centred.
|
||||
let mut png_bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png_bytes, 80, 40);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header().unwrap().write_image_data(&[200; 80 * 40 * 3]).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("wide.png"), png_bytes).unwrap();
|
||||
let turned = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_rotate", json!({ "doc": turned, "pages": [1], "degrees": 90 }));
|
||||
ok(&mut a, "stamp_custom", json!({ "doc": turned, "page": 1, "path": "wide.png", "rect": [50, 60, 150, 160] }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": turned }));
|
||||
let rect: Vec<f64> = list["comments"][0]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert_eq!(rect, [50.0, 85.0, 150.0, 135.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_split_with_equal_titles_keeps_every_part() {
|
||||
let dir = workdir("organize_dup_titles");
|
||||
@@ -4751,6 +5064,33 @@ fn links_through_tools() {
|
||||
assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// #4168: a Launch action whose file is a PDF (here with Windows `\` separators, relative to the
|
||||
/// source as a remote link is) opens that PDF in PdfCraft through link_open, within the root; one
|
||||
/// naming a program is listed but never followed.
|
||||
#[test]
|
||||
fn a_launch_link_to_a_pdf_opens_it_like_a_remote_link() {
|
||||
let (_base, root) = sandbox("launch-pdf");
|
||||
std::fs::create_dir_all(root.join("ts")).unwrap();
|
||||
std::fs::create_dir_all(root.join("se")).unwrap();
|
||||
std::fs::write(root.join("se/se_B2.pdf"), fixture(2)).unwrap();
|
||||
// The helper writes a GoToR; the action is renamed (the parser rebuilds the shifted offsets).
|
||||
let launch = |file: &str| String::from_utf8(remote_link_pdf(file, None)).unwrap().replace("/S /GoToR", "/S /Launch").into_bytes();
|
||||
std::fs::write(root.join("ts/TS_304.pdf"), launch(r"..\se\se_B2.pdf")).unwrap();
|
||||
std::fs::write(root.join("ts/program.pdf"), launch("tool.exe")).unwrap();
|
||||
let mut a = auto(&root);
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": "ts/TS_304.pdf" }))["doc"].as_u64().unwrap();
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": source }));
|
||||
let link = &info["links"][0]["target"];
|
||||
assert_eq!((link["action"].as_str(), link["remote_document"].as_str()), (Some("Launch"), Some("../se/se_B2.pdf")), "{info}");
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
assert_eq!(opened["path"].as_str().map(|p| p.replace('\\', "/")).as_deref(), Some("se/se_B2.pdf"), "{opened}");
|
||||
assert_eq!(opened["document"]["pages"], 2);
|
||||
let program = ok(&mut a, "doc_open", json!({ "path": "ts/program.pdf" }))["doc"].as_u64().unwrap();
|
||||
let before = a.session().docs().len();
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": program, "page": 1, "index": 1 })), ToolError::InvalidArgs(_) | ToolError::Failed(_)));
|
||||
assert_eq!(a.session().docs().len(), before, "nothing was opened or launched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
|
||||
let (_base, root) = sandbox("gotor-not-a-file");
|
||||
@@ -5573,6 +5913,28 @@ fn optimizing_through_tools() {
|
||||
assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap());
|
||||
assert_eq!(reduced["written"], true);
|
||||
assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap());
|
||||
|
||||
// Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off).
|
||||
let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" }));
|
||||
assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}");
|
||||
let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap();
|
||||
assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm"));
|
||||
|
||||
let v20 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v20.pdf", "compatibility": "2.0", "object_streams": false }));
|
||||
assert_eq!((v20["pdf_version"].as_str(), v20["object_streams"].as_bool()), (Some("2.0"), Some(false)), "{v20}");
|
||||
assert!(std::fs::read(dir.join("v20.pdf")).unwrap().starts_with(b"%PDF-2.0\n"));
|
||||
|
||||
// In retain mode, a catalog /Version override is reported as the effective PDF version.
|
||||
let mut cos14 = pdfcraft_cos::Document::open(std::sync::Arc::new(v14_bytes)).unwrap();
|
||||
let root = cos14.root().unwrap();
|
||||
cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.6"))).unwrap();
|
||||
let cat16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
|
||||
std::fs::write(dir.join("cat16.pdf"), &cat16).unwrap();
|
||||
let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap();
|
||||
let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false }));
|
||||
assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}");
|
||||
|
||||
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(
|
||||
a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
@@ -5580,6 +5942,76 @@ fn optimizing_through_tools() {
|
||||
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream
|
||||
/// needs is not what viewers read: the header is raised, and the later of the two is reported.
|
||||
#[test]
|
||||
fn retained_pdf_version_reports_the_header_raised_for_object_streams() {
|
||||
let dir = workdir("optimize-retain-version");
|
||||
let mut a = auto(&dir);
|
||||
let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" }));
|
||||
let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap();
|
||||
let root = cos.root().unwrap();
|
||||
cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.4"))).unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
|
||||
std::fs::write(dir.join("cat14.pdf"), &bytes).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "cat14.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "out.pdf", "compatibility": "retain", "object_streams": true }));
|
||||
let out = std::fs::read(dir.join("out.pdf")).unwrap();
|
||||
assert!(out.starts_with(b"%PDF-1.5\n") && out.windows(7).any(|w| w == b"/ObjStm"), "{r}");
|
||||
assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}");
|
||||
}
|
||||
|
||||
/// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for
|
||||
/// fewer than 250 bytes, before the code width grows.
|
||||
fn lzw_literals(data: &[u8]) -> Vec<u8> {
|
||||
let codes: Vec<u16> = std::iter::once(256).chain(data.iter().map(|&b| u16::from(b))).chain([257]).collect();
|
||||
let bits: Vec<bool> = codes.iter().flat_map(|c| (0..9).rev().map(move |i| c >> i & 1 == 1)).collect();
|
||||
bits.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |acc, (i, &b)| acc | (u8::from(b) << (7 - i)))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lzw_streams_are_stored_with_flate_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
|
||||
let dir = workdir("optimize-lzw");
|
||||
let ops = b"0.2 0.4 0.8 rg 72 72 200 100 re f 0 0 1 RG 72 300 m 400 500 l S".to_vec();
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let lzw = Stream::from_raw(Dict::from_iter([(b"Filter".to_vec(), Object::name("LZWDecode"))]), lzw_literals(&ops));
|
||||
assert_eq!(lzw.decoded_strict().unwrap(), ops);
|
||||
let content = doc.add(Object::Stream(lzw));
|
||||
let page = doc.add(Object::Dict(Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Page")),
|
||||
(b"Parent".to_vec(), Object::Ref(pages)),
|
||||
(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])),
|
||||
(b"Contents".to_vec(), Object::Ref(content)),
|
||||
])));
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("lzw.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "lzw.pdf" }))["doc"].as_u64().unwrap();
|
||||
let filters = |name: &str| {
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap();
|
||||
let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let pages = saved.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap();
|
||||
let page = saved.get(pages.get(b"Kids").and_then(|k| k.as_array()?.first()?.as_ref()).unwrap()).as_dict().cloned().unwrap();
|
||||
match &*saved.get(page.reference(b"Contents").unwrap()) {
|
||||
Object::Stream(s) => (s.dict.get(b"Filter").cloned(), s.decoded_strict().unwrap()),
|
||||
_ => panic!("the page content is not a stream"),
|
||||
}
|
||||
};
|
||||
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "flate.pdf" }));
|
||||
assert_eq!(r["lzw_streams"].as_u64(), Some(1), "{r}");
|
||||
assert_eq!(filters("flate.pdf"), (Some(Object::name("FlateDecode")), ops.clone()));
|
||||
let off = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "lzw_to_flate": false }));
|
||||
assert_eq!(off["lzw_streams"].as_u64(), Some(0), "{off}");
|
||||
assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops));
|
||||
}
|
||||
|
||||
/// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px
|
||||
/// wide on a 72 pt square (600 ppi), as some PDF printers write it.
|
||||
fn printed_in_strips(rows: usize) -> Vec<u8> {
|
||||
@@ -5802,6 +6234,60 @@ fn ocr_recognize_files_writes_searchable_copies() {
|
||||
assert!(found.to_string().contains("\"page\""), "{found}");
|
||||
}
|
||||
|
||||
/// Two inputs that share a file name (from different folders) would both write `out/scan.pdf`:
|
||||
/// the second output replaces the first while both results report success. The batch is refused
|
||||
/// up front, naming the file, before the folder is created, anything is read or the OCR engines
|
||||
/// load, so the outcome is the same with or without the models installed.
|
||||
#[test]
|
||||
fn ocr_recognize_files_refuses_inputs_that_share_a_file_name() {
|
||||
let dir = workdir("ocr-files-same-name");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/scan.pdf" }));
|
||||
let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/scan.pdf"], "folder": "out" }));
|
||||
assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("scan.pdf")), "{r:?}");
|
||||
assert!(!dir.join("out").exists(), "nothing was started: the output folder was not created");
|
||||
}
|
||||
|
||||
/// File names are compared without regard to case on Windows and macOS (`scan.pdf` and `Scan.PDF`
|
||||
/// are one file there), and exactly elsewhere, where they are two files and the batch is not
|
||||
/// refused.
|
||||
#[test]
|
||||
fn ocr_recognize_files_compares_names_the_way_the_filesystem_does() {
|
||||
let dir = workdir("ocr-files-case");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/Scan.PDF" }));
|
||||
let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/Scan.PDF"], "folder": "out" }));
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("Scan.PDF")), "{r:?}");
|
||||
assert!(!dir.join("out").exists(), "nothing was started: the output folder was not created");
|
||||
} else {
|
||||
assert!(!matches!(&r, Err(ToolError::InvalidArgs(_))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs with distinct names are not refused, and the batch writes one searchable copy per input
|
||||
/// under its own name. Without the models it stops at the models error, as it did before.
|
||||
#[test]
|
||||
fn ocr_recognize_files_with_distinct_names_runs_as_before() {
|
||||
let dir = workdir("ocr-files-distinct");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/other.pdf" }));
|
||||
if ok(&mut a, "ocr_status", json!({}))["available"] == true {
|
||||
let r = ok(&mut a, "ocr_recognize_files", json!({ "paths": ["one/scan.pdf", "two/other.pdf"], "folder": "out" }));
|
||||
assert!(r["files"][0]["error"].is_null() && r["files"][1]["error"].is_null(), "{r}");
|
||||
assert!(dir.join("out/scan.pdf").is_file() && dir.join("out/other.pdf").is_file(), "{r}");
|
||||
} else {
|
||||
let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/other.pdf"], "folder": "out" }));
|
||||
assert!(matches!(&r, Err(ToolError::Failed(m)) if m.contains("not installed")), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javascript_through_tools() {
|
||||
let dir = workdir("js");
|
||||
@@ -5845,6 +6331,12 @@ fn javascript_through_tools() {
|
||||
assert_eq!(total["value"], "3750");
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" }));
|
||||
assert!(r["error"].as_str().unwrap().contains("nope"));
|
||||
assert_eq!(r["document_errors"], json!([]));
|
||||
// A document script that throws (#3940) is reported, and the others and the script still run.
|
||||
ok(&mut a, "js_set_document_script", json!({ "doc": doc, "name": "zz", "script": "getField('Total').buttonSetIcon(null);" }));
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "money(5)" }));
|
||||
assert_eq!((r["error"].clone(), r["result"].clone()), (json!(null), json!("EUR 5,00")));
|
||||
assert!(r["document_errors"][0].as_str().unwrap().starts_with("document script 2: TypeError"), "{r}");
|
||||
assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false);
|
||||
assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err());
|
||||
}
|
||||
@@ -6355,6 +6847,33 @@ fn xfa_rich_text_max_length_is_reported_and_enforced_through_tools() {
|
||||
assert_eq!(family["value"], "éàöüñ");
|
||||
}
|
||||
|
||||
/// A link in a dynamic XFA form's rich text is a link on the laid-out page, as link_list reports
|
||||
/// it, and stays one after a save and a fresh open.
|
||||
#[test]
|
||||
fn links_in_xfa_rich_text_are_listed_through_tools() {
|
||||
let dir = workdir("xfa-links");
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<draw name="help" w="6in"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Questions? Email <a href="mailto:forms@example.com">forms@example.com</a>.</p></body></exData></value></draw>
|
||||
<field name="name" w="3in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
</subform></template></xdp:xdp>"##;
|
||||
std::fs::write(dir.join("links.pdf"), pdfcraft_xfa::fixtures::shell(xdp)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap();
|
||||
let urls = |a: &mut Automation, doc: u64| -> Vec<String> {
|
||||
let listed = ok(a, "link_list", json!({ "doc": doc }));
|
||||
let links = listed["links"].as_array().cloned().unwrap_or_default();
|
||||
links.iter().filter_map(|l| l["url"].as_str().map(str::to_string)).collect()
|
||||
};
|
||||
assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"]);
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "name": "Ada" } }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
|
||||
ok(&mut a, "doc_close", json!({ "doc": doc }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"], "one link after a save and a fresh open, not two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() {
|
||||
let dir = workdir("xfa");
|
||||
@@ -6422,7 +6941,9 @@ fn cut_stack_printing_through_tools() {
|
||||
&mut a,
|
||||
"doc_print",
|
||||
json!({
|
||||
"doc": doc, "pages": "2-10", "subset": "odd", "reverse": true,
|
||||
// Even pages by page number (#3984): 2, 4, 6, 8 and 10 (counting places in the range,
|
||||
// "even" would have been 3, 5, 7 and 9).
|
||||
"doc": doc, "pages": "2-10", "subset": "even", "reverse": true,
|
||||
"layout": "multiple", "order": "cut-stack", "per_sheet": 2, "auto_rotate": false,
|
||||
"orientation": "landscape", "path": "range.pdf"
|
||||
}),
|
||||
@@ -6686,6 +7207,35 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
assert_eq!(r["alerts"], json!(["Hello 4"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
|
||||
let dir = workdir("xfa-container-scripts");
|
||||
std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "form.pdf"}))["doc"].as_u64().unwrap();
|
||||
let fallback = "app.alert('This PDF form requires a newer version of Adobe Acrobat');";
|
||||
ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Console message"]));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
|
||||
ok(&mut a, "doc_close", json!({"doc":doc}));
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "js_document_scripts", json!({"doc":doc}))["scripts"], json!([{"name":"viewerFallback","script":fallback}]));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!([]));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Hello 2"]));
|
||||
ok(&mut a, "xfa_to_static", json!({"doc":doc}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""}));
|
||||
assert_eq!(out["alerts"].as_array().unwrap().len(), 1);
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(ok(&mut a, "js_run", json!({"doc":doc,"script":""}))["alerts"], json!([]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() {
|
||||
let dir = workdir("xfa-scripts-off");
|
||||
@@ -7841,6 +8391,46 @@ fn a_root_less_write_output_is_atomic_too() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_and_deleting_files_through_the_tools() {
|
||||
let dir = workdir("attach");
|
||||
std::fs::write(dir.join("notes.txt"), b"keep this with the PDF").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().is_empty());
|
||||
// A text file and a PDF, the PDF under a name of its own; the second notes.txt is kept apart.
|
||||
let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt", "description": "Meeting notes" }));
|
||||
assert_eq!(r["name"], "notes.txt");
|
||||
let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "b.pdf", "name": "annex.pdf" }));
|
||||
assert_eq!(r["name"], "annex.pdf");
|
||||
let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt" }));
|
||||
assert_eq!(r["name"], "notes.txt (2)");
|
||||
let listed = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].clone();
|
||||
let names: Vec<&str> = listed.as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["annex.pdf", "notes.txt", "notes.txt (2)"]);
|
||||
assert_eq!(listed[1]["description"], "Meeting notes");
|
||||
assert_eq!(listed[1]["size"], 22);
|
||||
// Saved and reopened, the attachment is there with its bytes.
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "with-notes.pdf" }));
|
||||
let saved = std::fs::read(dir.join("with-notes.pdf")).unwrap();
|
||||
let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap();
|
||||
let att = info.attachments.iter().find(|x| x.name == "notes.txt").expect("notes.txt is in the file");
|
||||
assert_eq!(pdfcraft_render::attachment_data(&saved, None, att).unwrap(), b"keep this with the PDF");
|
||||
// Deleting one leaves the others; a name that is not there is refused; undo brings it back.
|
||||
ok(&mut a, "attachment_delete", json!({ "doc": doc, "name": "notes.txt" }));
|
||||
let names: Vec<String> = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x["name"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, ["annex.pdf", "notes.txt (2)"]);
|
||||
assert!(a.call("attachment_delete", &json!({ "doc": doc, "name": "notes.txt" })).is_err());
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().len(), 3);
|
||||
assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
|
||||
@@ -8196,3 +8786,134 @@ fn comments_are_reshaped_through_the_tool() {
|
||||
assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends");
|
||||
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// #3193: bookmark_list reports a bookmark's colour and style (ISO 32000-2 §12.3.3, Tables 151
|
||||
/// and 152), only where they differ from black and regular. The list is read from the opened file
|
||||
/// and, once the document has been edited, from the edited one; both read them alike.
|
||||
/// bookmark_set_style changes them, and they survive a save.
|
||||
#[test]
|
||||
fn bookmark_list_reports_colour_and_style() {
|
||||
let dir = workdir("bookmark-style");
|
||||
let objects: Vec<&[u8]> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R >>",
|
||||
b"<< /Type /Outlines /First 5 0 R /Last 7 0 R /Count 3 >>",
|
||||
b"<< /Title (Plain) /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>",
|
||||
b"<< /Title (Red bold) /Parent 4 0 R /Prev 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] /C [1 0 0] /F 2 >>",
|
||||
b"<< /Title (Blue italic) /Parent 4 0 R /Prev 6 0 R /Dest [3 0 R /Fit] /C [0 0 1] /F 1 >>",
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("styled.pdf"), bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
|
||||
let style = |list: &Value| -> Vec<(Value, Value, Value)> {
|
||||
list.as_array().unwrap().iter().map(|b| (b["color"].clone(), b["bold"].clone(), b["italic"].clone())).collect()
|
||||
};
|
||||
let expected = vec![
|
||||
(Value::Null, Value::Null, Value::Null),
|
||||
(json!([1.0, 0.0, 0.0]), json!(true), Value::Null),
|
||||
(json!([0.0, 0.0, 1.0]), Value::Null, json!(true)),
|
||||
];
|
||||
let opened = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||||
assert_eq!(style(&opened), expected, "as opened: {opened}");
|
||||
ok(&mut a, "bookmark_rename", json!({ "doc": doc, "path": [1], "title": "Still plain" }));
|
||||
let edited = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||||
assert_eq!(style(&edited), expected, "after an edit: {edited}");
|
||||
|
||||
// bookmark_set_style changes only what it is given.
|
||||
ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [1], "color": [0, 0.5, 0], "italic": true }));
|
||||
ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [2], "bold": false }));
|
||||
ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [3], "color": [0, 0, 0] }));
|
||||
let set = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||||
assert_eq!(
|
||||
style(&set),
|
||||
vec![
|
||||
(json!([0.0, 0.5, 0.0]), Value::Null, json!(true)),
|
||||
(json!([1.0, 0.0, 0.0]), Value::Null, Value::Null),
|
||||
(Value::Null, Value::Null, json!(true))
|
||||
],
|
||||
"{set}"
|
||||
);
|
||||
// A colour and a style in one call are one undo step.
|
||||
ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [2], "color": [0, 0, 0], "bold": true, "italic": true }));
|
||||
let both = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||||
assert_eq!(style(&both)[1], (Value::Null, json!(true), json!(true)), "{both}");
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
let undone = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||||
assert_eq!(style(&undone), style(&set), "one undo takes back both: {undone}");
|
||||
for bad in [
|
||||
json!({ "doc": doc, "path": [1] }),
|
||||
json!({ "doc": doc, "path": [1], "color": [2, 0, 0] }),
|
||||
json!({ "doc": doc, "path": [1], "color": [1, 0] }),
|
||||
] {
|
||||
assert!(matches!(a.call("bookmark_set_style", &bad), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||||
}
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "restyled.pdf" }));
|
||||
let mut b = auto(&dir);
|
||||
let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved");
|
||||
}
|
||||
|
||||
/// `page_render`'s pixels show a rubber stamp written without an appearance as the stamp its
|
||||
/// `/Name` names (ISO 32000-2 Table 184), and one with an unknown name as a grey placeholder.
|
||||
#[test]
|
||||
fn page_render_draws_stamps_without_an_appearance() {
|
||||
let dir = workdir("stamps-without-appearance");
|
||||
let objects: Vec<&[u8]> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 100] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [10 20 130 80] /Name /Approved >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [170 20 290 80] /Name /MyCustomStamp >>",
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("stamps.pdf"), bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "stamps.pdf" }))["doc"].as_u64().unwrap();
|
||||
let (width, _, pixels) = a.render_pixels(doc, 1, 1.0).unwrap();
|
||||
let pixels = pixels.as_ref();
|
||||
// Ink in columns `x0..x1` of rows 20..80, which are the stamps' rows whether counted from the top or the bottom.
|
||||
let ink = |x0: u32, x1: u32| -> Vec<[u8; 3]> {
|
||||
(20..80u32)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (y, x)))
|
||||
.map(|(y, x)| {
|
||||
let i = ((y * width + x) * 4) as usize;
|
||||
[pixels[i], pixels[i + 1], pixels[i + 2]]
|
||||
})
|
||||
.filter(|p| p.iter().any(|v| *v < 200))
|
||||
.collect()
|
||||
};
|
||||
let approved = ink(10, 130);
|
||||
let green = approved.iter().filter(|p| p[1] > p[0] && p[1] > p[2]).count();
|
||||
assert!(green > 300 && green * 10 > approved.len() * 9, "Approved is drawn green: {green} of {}", approved.len());
|
||||
let custom = ink(170, 290);
|
||||
let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count();
|
||||
assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len());
|
||||
}
|
||||
@@ -152,6 +152,7 @@ fn tools_that_read_a_path_are_held_to_the_read_root() {
|
||||
("doc_create_multiple", json!({"paths": [secret], "mode": "combine", "out": out})),
|
||||
("doc_create", json!({"from": "images", "paths": [pic]})),
|
||||
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})),
|
||||
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})),
|
||||
("page_add_image", json!({"doc": 1, "page": 1, "path": pic})),
|
||||
("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})),
|
||||
("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})),
|
||||
|
||||
@@ -190,6 +190,54 @@ fn guarded(a: &mut Automation, tool: &str, args: &Value) -> std::result::Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// A new one-page blank document, by id.
|
||||
fn blank_doc(a: &mut Automation) -> u64 {
|
||||
let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document");
|
||||
created
|
||||
.into_iter()
|
||||
.find_map(|c| match c {
|
||||
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
|
||||
pdfcraft_automation::Content::Png { .. } => None,
|
||||
})
|
||||
.expect("the new document has an id")
|
||||
}
|
||||
|
||||
/// Delete everything inside `dir`, so one tool never sees files an earlier tool wrote: a digital ID
|
||||
/// that `sign_id_create` wrote to the `in.pdf` placeholder would stop every later `sign_document`
|
||||
/// call at its decrypt, before the argument under test.
|
||||
///
|
||||
/// The entries are reached through the canonical path, as the tools reach them: on Windows that
|
||||
/// is a `\\?\` path, whose names are taken literally. Through the plain path a file the tools
|
||||
/// named `" "` would lose its trailing space and name `dir` itself, which would then be deleted.
|
||||
fn empty_dir(dir: &Path) {
|
||||
let dir = dir.canonicalize().expect("the root exists");
|
||||
for entry in std::fs::read_dir(&dir).expect("read the root") {
|
||||
let entry = entry.unwrap_or_else(|e| panic!("list {}: {e}", dir.display()));
|
||||
let path = entry.path();
|
||||
let is_dir = entry.file_type().unwrap_or_else(|e| panic!("file type of {}: {e}", path.display())).is_dir();
|
||||
let removed = if is_dir { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) };
|
||||
removed.unwrap_or_else(|e| panic!("remove {} before the next tool: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Real values for the arguments a tool checks before the others, so a hostile value reaches the
|
||||
/// argument it is meant for instead of failing on a placeholder. `sign_document` checks its
|
||||
/// digital ID first and its document second: this ID opens with its password, and each call gets
|
||||
/// a fresh document (which the caller closes), since a successful call signs it. The ID is a test
|
||||
/// key with a low key derivation count, so the hundreds of calls that open it stay fast.
|
||||
fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map<String, Value> {
|
||||
match tool {
|
||||
"sign_document" => {
|
||||
let id = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sign/tests/data/ec-p256.p12");
|
||||
std::fs::copy(&id, root.join("id.p12")).expect("copy the test digital ID");
|
||||
let doc = blank_doc(a);
|
||||
let args = json!({ "doc": doc, "id": "id.p12", "password": "test", "out": "signed.pdf" });
|
||||
args.as_object().cloned().unwrap_or_default()
|
||||
}
|
||||
_ => serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tool_panics_on_hostile_arguments() {
|
||||
let root = tmp("args");
|
||||
@@ -199,6 +247,7 @@ fn no_tool_panics_on_hostile_arguments() {
|
||||
let mut calls = 0usize;
|
||||
|
||||
for def in tools() {
|
||||
empty_dir(&root);
|
||||
// The whole arguments object, not just its properties.
|
||||
for bad in [json!(null), json!([]), json!("string"), json!(7), json!(true), json!({})] {
|
||||
calls += 1;
|
||||
@@ -210,13 +259,24 @@ fn no_tool_panics_on_hostile_arguments() {
|
||||
for prop in &props {
|
||||
for value in &values {
|
||||
let mut obj = plausible(&def);
|
||||
let fixed = fixture(&mut a, &root, def.name);
|
||||
let fixture_doc = fixed.get("doc").cloned();
|
||||
obj.extend(fixed);
|
||||
obj.insert(prop.clone(), value.clone());
|
||||
calls += 1;
|
||||
if let Err(e) = guarded(&mut a, def.name, &Value::Object(obj)) {
|
||||
failures.push(e);
|
||||
}
|
||||
// Later tools pass placeholder document ids, which must not find this one open.
|
||||
if let Some(doc) = fixture_doc {
|
||||
a.call("doc_close", &json!({ "doc": doc, "discard_changes": true })).expect("close the fixture's document");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A signed file shows the fixture got calls past the ID and document checks.
|
||||
if def.name == "sign_document" {
|
||||
assert!(root.join("signed.pdf").is_file(), "no sign_document call signed: the hostile values stop at the ID or document");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("hostile arguments: {calls} calls over {} tools, {} panics", tools().len(), failures.len());
|
||||
@@ -256,14 +316,7 @@ fn no_tool_escapes_the_root() {
|
||||
let mut a = Automation::new().with_root(&root).expect("root");
|
||||
|
||||
// A real document, so tools reach their path arguments instead of failing on `doc` first.
|
||||
let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document");
|
||||
let doc = created
|
||||
.into_iter()
|
||||
.find_map(|c| match c {
|
||||
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
|
||||
pdfcraft_automation::Content::Png { .. } => None,
|
||||
})
|
||||
.expect("the new document has an id");
|
||||
let doc = blank_doc(&mut a);
|
||||
|
||||
let mut leaked = Vec::new();
|
||||
let mut tried = 0usize;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod attachment_key;
|
||||
mod automation;
|
||||
mod automation_roots;
|
||||
mod batch;
|
||||
|
||||
+154
-92
@@ -52,6 +52,12 @@ impl Op {
|
||||
pub fn name(&self, i: usize) -> Option<&[u8]> {
|
||||
self.operands.get(i).and_then(Object::as_name)
|
||||
}
|
||||
|
||||
/// The last operand as a name: the one `Do` and the other one-operand operators act on, since
|
||||
/// the renderer takes operands from the end and ignores any surplus before them.
|
||||
pub fn last_name(&self) -> Option<&[u8]> {
|
||||
self.operands.last().and_then(Object::as_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed stream.
|
||||
@@ -86,108 +92,164 @@ pub fn parse(data: &[u8]) -> Parsed {
|
||||
parse_pieces(data, &[])
|
||||
}
|
||||
|
||||
/// The operators of a (decoded) content stream, read one at a time, as [`parse`] reads them. A
|
||||
/// caller that stops early or keeps only a summary never holds every operator of the stream.
|
||||
pub fn ops(data: &[u8]) -> Ops<'_> {
|
||||
Ops::new(data, &[])
|
||||
}
|
||||
|
||||
/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment
|
||||
/// crosses.
|
||||
fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
|
||||
let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() };
|
||||
let mut boundary = 0;
|
||||
let mut lx = Lexer::new(data, 0);
|
||||
let mut operands: Vec<Object> = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
loop {
|
||||
let trivia = lx.pos;
|
||||
lx.skip_ws();
|
||||
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
|
||||
// string, a name, an array or dictionary operand or inline image data is not one.
|
||||
if boundary < ends.len() {
|
||||
comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges);
|
||||
let mut reader = Ops::new(data, ends);
|
||||
let ops = reader.by_ref().collect();
|
||||
Parsed { ops, skipped: reader.skipped, ambiguous_images: reader.ambiguous_images, comment_edges: reader.comment_edges }
|
||||
}
|
||||
|
||||
/// The iterator [`ops`] returns.
|
||||
pub struct Ops<'a> {
|
||||
data: &'a [u8],
|
||||
lx: Lexer<'a>,
|
||||
operands: Vec<Object>,
|
||||
start: Option<usize>,
|
||||
skipped: usize,
|
||||
ambiguous_images: usize,
|
||||
ends: &'a [usize],
|
||||
boundary: usize,
|
||||
comment_edges: Vec<(bool, bool)>,
|
||||
}
|
||||
|
||||
impl<'a> Ops<'a> {
|
||||
fn new(data: &'a [u8], ends: &'a [usize]) -> Self {
|
||||
Ops {
|
||||
data,
|
||||
lx: Lexer::new(data, 0),
|
||||
operands: Vec::new(),
|
||||
start: None,
|
||||
skipped: 0,
|
||||
ambiguous_images: 0,
|
||||
ends,
|
||||
boundary: 0,
|
||||
comment_edges: vec![(false, false); ends.len()],
|
||||
}
|
||||
let Some(&b) = data.get(lx.pos) else { break };
|
||||
let at = lx.pos;
|
||||
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
if operand_start {
|
||||
match lx.object() {
|
||||
Ok(o) => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(o);
|
||||
}
|
||||
Err(_) if b == b'(' => {
|
||||
// An unterminated string runs to the end of the stream.
|
||||
out.skipped += data.len() - at;
|
||||
lx.pos = data.len();
|
||||
}
|
||||
Err(_) => {
|
||||
let read = lx.pos.saturating_sub(at);
|
||||
#[cfg(test)]
|
||||
tests::RESCANNED.with(|c| c.set(c.get() + read));
|
||||
let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
if number && read > MAX_RETRIED_TOKEN {
|
||||
// A long unreadable number is skipped whole: retrying from each of its
|
||||
// bytes would read the rest of it again every time.
|
||||
out.skipped += read;
|
||||
} else {
|
||||
// Skip the offending byte and carry on.
|
||||
lx.pos = at + 1;
|
||||
out.skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes read so far that could not be read as operands or operators.
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.skipped
|
||||
}
|
||||
|
||||
/// Inline images read so far whose end is this parser's guess (as in
|
||||
/// [`Parsed::ambiguous_images`]).
|
||||
pub fn ambiguous_images(&self) -> usize {
|
||||
self.ambiguous_images
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Ops<'_> {
|
||||
type Item = Op;
|
||||
|
||||
fn next(&mut self) -> Option<Op> {
|
||||
let Ops { data, lx, operands, start, skipped, ambiguous_images, ends, boundary, comment_edges: edges } = self;
|
||||
let (data, ends): (&[u8], &[usize]) = (data, ends);
|
||||
loop {
|
||||
let trivia = lx.pos;
|
||||
lx.skip_ws();
|
||||
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
|
||||
// string, a name, an array or dictionary operand or inline image data is not one.
|
||||
if *boundary < ends.len() {
|
||||
comment_edges(data, trivia..lx.pos, ends, boundary, edges);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_delim(b) {
|
||||
// A stray `)`, `>`, `]`, `{`, `}`.
|
||||
lx.pos += 1;
|
||||
out.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let kw = lx.token();
|
||||
if kw.is_empty() {
|
||||
lx.pos = at + 1;
|
||||
out.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
match kw {
|
||||
b"true" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(true));
|
||||
}
|
||||
b"false" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(false));
|
||||
}
|
||||
b"null" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Null);
|
||||
}
|
||||
b"BI" => {
|
||||
let image = inline_image(data, at, lx.pos);
|
||||
out.ambiguous_images += usize::from(image.ambiguous);
|
||||
let end = image.end;
|
||||
lx.pos = end;
|
||||
match image.image {
|
||||
Some((dict, img)) => {
|
||||
out.ops.push(Op {
|
||||
op: b"BI".to_vec(),
|
||||
operands: Vec::new(),
|
||||
inline: Some((dict, img)),
|
||||
span: start.take().unwrap_or(at)..end,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
out.skipped += end.saturating_sub(at);
|
||||
start = None;
|
||||
}
|
||||
}
|
||||
let Some(&b) = data.get(lx.pos) else {
|
||||
// Operands left without an operator.
|
||||
*skipped += operands.len();
|
||||
operands.clear();
|
||||
return None;
|
||||
};
|
||||
let at = lx.pos;
|
||||
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
if operand_start {
|
||||
match lx.object() {
|
||||
Ok(o) => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(o);
|
||||
}
|
||||
Err(_) if b == b'(' => {
|
||||
// An unterminated string runs to the end of the stream.
|
||||
*skipped += data.len() - at;
|
||||
lx.pos = data.len();
|
||||
}
|
||||
Err(_) => {
|
||||
let read = lx.pos.saturating_sub(at);
|
||||
#[cfg(test)]
|
||||
tests::RESCANNED.with(|c| c.set(c.get() + read));
|
||||
let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
if number && read > MAX_RETRIED_TOKEN {
|
||||
// A long unreadable number is skipped whole: retrying from each of its
|
||||
// bytes would read the rest of it again every time.
|
||||
*skipped += read;
|
||||
} else {
|
||||
// Skip the offending byte and carry on.
|
||||
lx.pos = at + 1;
|
||||
*skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
let s = start.take().unwrap_or(at);
|
||||
out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos });
|
||||
if is_delim(b) {
|
||||
// A stray `)`, `>`, `]`, `{`, `}`.
|
||||
lx.pos += 1;
|
||||
*skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let kw = lx.token();
|
||||
if kw.is_empty() {
|
||||
lx.pos = at + 1;
|
||||
*skipped += 1;
|
||||
continue;
|
||||
}
|
||||
match kw {
|
||||
b"true" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(true));
|
||||
}
|
||||
b"false" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(false));
|
||||
}
|
||||
b"null" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Null);
|
||||
}
|
||||
b"BI" => {
|
||||
let image = inline_image(data, at, lx.pos);
|
||||
*ambiguous_images += usize::from(image.ambiguous);
|
||||
let end = image.end;
|
||||
lx.pos = end;
|
||||
operands.clear();
|
||||
match image.image {
|
||||
Some((dict, img)) => {
|
||||
return Some(Op {
|
||||
op: b"BI".to_vec(),
|
||||
operands: Vec::new(),
|
||||
inline: Some((dict, img)),
|
||||
span: start.take().unwrap_or(at)..end,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
*skipped += end.saturating_sub(at);
|
||||
*start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let s = start.take().unwrap_or(at);
|
||||
return Some(Op { op: kw.to_vec(), operands: std::mem::take(operands), inline: None, span: s..lx.pos });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.skipped += operands.len();
|
||||
out
|
||||
}
|
||||
|
||||
/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and
|
||||
|
||||
@@ -203,6 +203,9 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them()
|
||||
let parsed = parse(src);
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc");
|
||||
assert_eq!(parsed.ambiguous_images, ambiguous);
|
||||
let mut reader = ops(src);
|
||||
reader.by_ref().for_each(drop);
|
||||
assert_eq!(reader.ambiguous_images(), ambiguous, "the streaming reader counts it too");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -897,6 +897,11 @@ impl Document {
|
||||
&self.version
|
||||
}
|
||||
|
||||
/// Set the PDF header version (`"1.4"`, `"1.7"`, `"2.0"`, …) written by a full save.
|
||||
pub fn set_version(&mut self, version: impl Into<String>) {
|
||||
self.version = version.into();
|
||||
}
|
||||
|
||||
pub fn trailer(&self) -> &Dict {
|
||||
&self.trailer
|
||||
}
|
||||
|
||||
@@ -178,7 +178,10 @@ fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandl
|
||||
Object::Stream(s) => {
|
||||
let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect();
|
||||
let ty = s.dict.name(b"Type");
|
||||
if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) {
|
||||
// Cross-reference streams are never encrypted (§7.5.8), but only reading may trust the
|
||||
// label: a stream a producer calls /XRef stays plaintext if writing believes it, and the
|
||||
// writer builds its own cross-reference streams outside this transform.
|
||||
if (decrypt && ty == Some(b"XRef")) || (ty == Some(b"Metadata") && !h.encrypts_metadata()) {
|
||||
return Object::Stream(Stream { dict, raw: s.raw.clone() });
|
||||
}
|
||||
let mut named = named_crypt_filter(&s.dict);
|
||||
|
||||
@@ -477,3 +477,20 @@ fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() {
|
||||
assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A content stream that calls itself `/Type /XRef` is still encrypted: only reading trusts the label.
|
||||
#[test]
|
||||
fn a_stream_labelled_xref_is_encrypted_when_saved() {
|
||||
let mut doc = Document::open(Arc::new(fixture_with(" /Type /XRef"))).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: Algorithm::Aes256,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: -4,
|
||||
encrypt_metadata: true,
|
||||
seed: [9; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the labelled stream was written in the clear");
|
||||
}
|
||||
@@ -27,5 +27,7 @@ Every function returns a `pdfcraft_cos::Document`; the caller writes it.
|
||||
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
|
||||
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
|
||||
Missing, null or malformed Decode arrays keep the default mapping.
|
||||
The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited.
|
||||
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
|
||||
The existing 16-bit path still uses each sample's high byte.
|
||||
JPEG image Decode handling remains limited.
|
||||
ICC transforms and calibrated color conversion remain outside this export path.
|
||||
@@ -465,7 +465,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8
|
||||
Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect())
|
||||
}
|
||||
|
||||
/// A one-component mask image as 8-bit values (its `/Decode` inversion applied), scaled to
|
||||
/// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to
|
||||
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000, JBIG2 and CCITT masks).
|
||||
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
|
||||
@@ -491,14 +491,11 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
|
||||
unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
|
||||
}
|
||||
};
|
||||
let invert = m
|
||||
.dict
|
||||
.get(b"Decode")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
|
||||
.unwrap_or(false);
|
||||
if invert {
|
||||
px.iter_mut().for_each(|v| *v = 255 - *v);
|
||||
if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) {
|
||||
for value in &mut px {
|
||||
let t = f64::from(*value) / 255.0;
|
||||
*value = (((1.0 - t) * lo + t * hi).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
if (mw, mh) == (w, h) {
|
||||
return Some(px);
|
||||
|
||||
@@ -269,7 +269,7 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
||||
0xEE if seg.starts_with(b"Adobe") => adobe = true,
|
||||
// APP2 ICC profile, possibly split over several chunks.
|
||||
0xE2 => {
|
||||
if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(|r| r.split_first_chunk::<2>()) {
|
||||
if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(<[u8]>::split_first_chunk::<2>) {
|
||||
icc_chunks.push((*seq, *count, data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +682,74 @@ fn jpeg_images_with_a_soft_mask_export_as_png_with_alpha() {
|
||||
assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &real_jpeg()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Both image export paths apply mask intervals independently of the image's component Decode.
|
||||
fn soft_mask_decode_ranges_become_exported_alpha() {
|
||||
for indirect in [false, true] {
|
||||
for (range, expected) in [
|
||||
([0.25, 0.75], [64, 128, 191]),
|
||||
([0.75, 0.25], [191, 127, 64]),
|
||||
([1.0, 0.0], [255, 127, 0]),
|
||||
([-1.0, 2.0], [0, 129, 255]),
|
||||
([0.5, 0.5], [128, 128, 128]),
|
||||
] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let endpoints = range.into_iter().map(|v| {
|
||||
let value = Object::Real(v);
|
||||
if indirect { Object::Ref(doc.add(value)) } else { value }
|
||||
});
|
||||
let decode = Object::Array(endpoints.collect());
|
||||
let decode = if indirect { Object::Ref(doc.add(decode)) } else { decode };
|
||||
let mut mask = image_dict(3, 1, "DeviceGray", 8);
|
||||
mask.set(b"Decode".to_vec(), decode);
|
||||
let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0, 128, 255])));
|
||||
let mut image = image_dict(3, 1, "DeviceRGB", 8);
|
||||
image.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
image.set(b"Decode".to_vec(), decode_array(&[1.0, 0.0, 0.0, 1.0, 0.0, 1.0]));
|
||||
Stream::from_raw(image, [0, 64, 255].repeat(3))
|
||||
});
|
||||
let doc = reopen(&doc);
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
assert_eq!(out.images.len(), 1);
|
||||
let pixels = exported_png_pixels(&out.images[0].data);
|
||||
assert_eq!(alphas(&pixels), expected, "Decode {range:?}, indirect={indirect}");
|
||||
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 64, 255]), "image component Decode stays independent");
|
||||
let (ext, bytes) = image_file(&doc, out.images[0].object).unwrap();
|
||||
assert_eq!(ext, "png");
|
||||
assert_eq!(exported_png_pixels(&bytes), pixels, "Save Image As and Export all images agree");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Packed and JPEG samples map to opacity before resizing to their image's dimensions.
|
||||
fn soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing() {
|
||||
for jpeg in [false, true] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mask = if jpeg {
|
||||
let mut mask = image_dict(4, 2, "DeviceGray", 8);
|
||||
mask.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
mask.set(b"Decode".to_vec(), decode_array(&[0.25, 0.25]));
|
||||
Stream::from_raw(mask, real_jpeg())
|
||||
} else {
|
||||
let mut mask = image_dict(2, 1, "DeviceGray", 1);
|
||||
mask.set(b"Decode".to_vec(), decode_array(&[-1.0, 2.0]));
|
||||
Stream::from_raw(mask, vec![0b0100_0000])
|
||||
};
|
||||
let mask = doc.add(Object::Stream(mask));
|
||||
let mut image = image_dict(4, 2, "DeviceRGB", 8);
|
||||
image.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
Stream::from_raw(image, vec![10; 24])
|
||||
});
|
||||
// Clipping before resizing differs from resizing first, then clipping to [0, 255].
|
||||
let expected = if jpeg { vec![64; 8] } else { vec![0, 64, 191, 255, 0, 64, 191, 255] };
|
||||
assert_eq!(alphas(&exported_rgba(&reopen(&doc)).1), expected, "jpeg={jpeg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha() {
|
||||
// A 2×1 soft mask on a 4×2 image is scaled to the image's size.
|
||||
|
||||
+96
-10
@@ -20,6 +20,9 @@ use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, p
|
||||
|
||||
const TAG: &str = "Added";
|
||||
|
||||
/// The most character spacing an added text takes either way, in points: as far as the font size goes.
|
||||
pub const CHAR_SPACING_LIMIT: f64 = 500.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Family {
|
||||
#[default]
|
||||
@@ -116,6 +119,8 @@ pub struct AddedText {
|
||||
pub size: f64,
|
||||
pub color: [f64; 3],
|
||||
pub align: Align,
|
||||
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
|
||||
pub char_spacing: f64,
|
||||
}
|
||||
|
||||
impl Default for AddedText {
|
||||
@@ -129,6 +134,7 @@ impl Default for AddedText {
|
||||
size: 12.0,
|
||||
color: [0.0; 3],
|
||||
align: Align::Left,
|
||||
char_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +198,9 @@ impl Content {
|
||||
fn scaled(self, factor: f64) -> Content {
|
||||
match self {
|
||||
_ if factor == 1.0 => self,
|
||||
Content::Text(t) => Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, ..t }),
|
||||
Content::Text(t) => {
|
||||
Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, char_spacing: t.char_spacing * factor, ..t })
|
||||
}
|
||||
Content::Image(i) => Content::Image(AddedImage { rect: i.rect.map(|v| v * factor), ..i }),
|
||||
}
|
||||
}
|
||||
@@ -230,6 +238,12 @@ fn norm(r: [f64; 4]) -> [f64; 4] {
|
||||
[r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]
|
||||
}
|
||||
|
||||
/// The advance of `s` in the item's standard font: its glyphs' widths and the character spacing
|
||||
/// after each of them, as `Tc` moves the pen (ISO 32000-2 §9.3.2).
|
||||
fn latin_width(t: &AddedText, s: &str) -> f64 {
|
||||
t.family.width(s, t.size, t.bold, t.italic) + t.char_spacing * s.chars().count() as f64
|
||||
}
|
||||
|
||||
/// The lines of a text item after wrapping to its box width.
|
||||
pub fn lines(t: &AddedText) -> Vec<String> {
|
||||
wrapped(t).into_iter().map(|(line, _)| line).collect()
|
||||
@@ -264,7 +278,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
|
||||
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
|
||||
// of an em apart.
|
||||
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
|
||||
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) };
|
||||
let space = measure(" ");
|
||||
let (mut line, mut line_w) = (String::new(), 0.0);
|
||||
for word in para.split(' ') {
|
||||
@@ -292,10 +306,10 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, Edit
|
||||
let mut atoms = Vec::new();
|
||||
for piece in pieces {
|
||||
match piece {
|
||||
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))),
|
||||
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size + t.char_spacing))),
|
||||
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
|
||||
let text = c.to_string();
|
||||
let w = t.family.width(&text, t.size, t.bold, t.italic);
|
||||
let w = latin_width(t, &text);
|
||||
(text, w)
|
||||
})),
|
||||
}
|
||||
@@ -459,8 +473,9 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
|
||||
let width = pieces
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic),
|
||||
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
|
||||
Piece::Latin(s) => latin_width(t, s),
|
||||
// Each cluster is shown on its own, so the spacing follows each once.
|
||||
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::<f64>(),
|
||||
})
|
||||
.sum();
|
||||
Ok((pieces, width))
|
||||
@@ -469,7 +484,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
|
||||
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
|
||||
/// then reports why).
|
||||
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
|
||||
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic))
|
||||
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s))
|
||||
}
|
||||
|
||||
fn arabic_error(e: GlyphError, text: &str) -> EditError {
|
||||
@@ -509,6 +524,61 @@ pub fn text_rect(t: &AddedText) -> [f64; 4] {
|
||||
[r[0], r[3] - h, r[2], r[3]]
|
||||
}
|
||||
|
||||
/// How far each paste onto a page already holding the copy is offset, down and to the right, so a
|
||||
/// copy doesn't hide what it was copied from (points).
|
||||
pub const PASTE_STEP: f64 = 18.0;
|
||||
|
||||
/// Where a pasted item of box `rect` goes on a page of `size` (display space): with its top-left
|
||||
/// corner at `at`, else where it was, `steps` paste steps ([`PASTE_STEP`]) down and to the right.
|
||||
/// It is kept on the page; one larger than the page keeps its left and top edges on it. A page
|
||||
/// or point that isn't a number leaves the box where it was.
|
||||
pub fn paste_rect(rect: [f64; 4], size: [f64; 2], steps: u32, at: Option<[f64; 2]>) -> [f64; 4] {
|
||||
let [x0, y0, x1, y1] = norm(rect);
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let (left, top) = match at {
|
||||
Some([x, y]) => (x, y),
|
||||
None => {
|
||||
let d = PASTE_STEP * f64::from(steps);
|
||||
(x0 + d, y1 - d)
|
||||
}
|
||||
};
|
||||
if !size.iter().chain(&[left, top, w, h]).all(|v| v.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let left = left.min(size[0] - w).max(0.0);
|
||||
let top = top.max(h).min(size[1]);
|
||||
[left, top - h, left + w, top]
|
||||
}
|
||||
|
||||
/// Most paste steps [`paste_place`] tries before giving up on finding a free place.
|
||||
const MAX_PASTE_STEPS: u32 = 64;
|
||||
|
||||
/// Where Edit ▸ Paste puts `content` on a page of `size` already holding `items` (display space):
|
||||
/// where it was, or as many paste steps down and to the right as it takes not to land exactly on
|
||||
/// a copy of it (the original it was copied from, or an earlier paste).
|
||||
pub fn paste_place<'a>(items: impl IntoIterator<Item = &'a Content>, content: &Content, size: [f64; 2]) -> [f64; 4] {
|
||||
let same = copy_key(content);
|
||||
let copies: Vec<[f64; 4]> = items.into_iter().filter(|i| copy_key(i) == same).map(Content::rect).collect();
|
||||
let taken = |rect: [f64; 4]| copies.iter().any(|c| c.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5));
|
||||
let mut rect = paste_rect(content.rect(), size, 0, None);
|
||||
for steps in 1..=MAX_PASTE_STEPS {
|
||||
if !taken(rect) {
|
||||
break;
|
||||
}
|
||||
rect = paste_rect(content.rect(), size, steps, None);
|
||||
}
|
||||
rect
|
||||
}
|
||||
|
||||
/// What makes two items copies of each other for [`paste_place`]: all but the box, and for an
|
||||
/// image all but the object holding its picture, which a paste into another document stores anew.
|
||||
fn copy_key(c: &Content) -> Content {
|
||||
match c.with_rect([0.0; 4]) {
|
||||
Content::Image(img) => Content::Image(AddedImage { image: ObjRef::new(0, 0), ..img }),
|
||||
text => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest explicit line before wrapping, measured with the PDF writing fonts.
|
||||
pub fn text_width(t: &AddedText) -> f64 {
|
||||
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
|
||||
@@ -596,6 +666,9 @@ fn draw_laid_shaped(
|
||||
}
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
|
||||
// Shaped glyphs are painted in visual order. Keep the original line's typing order
|
||||
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
|
||||
@@ -621,7 +694,7 @@ fn draw_laid_shaped(
|
||||
out.extend(literal(&win_ansi(s)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
current = Some(latin.as_str());
|
||||
pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64;
|
||||
pen += latin_width(t, s) + tw * s.matches(' ').count() as f64;
|
||||
}
|
||||
Piece::Shaped(clusters) => {
|
||||
for c in clusters {
|
||||
@@ -636,7 +709,7 @@ fn draw_laid_shaped(
|
||||
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
|
||||
out.extend(literal(&[code]));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
pen += c.advance * t.size + if c.text == " " { tw } else { 0.0 };
|
||||
pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -788,9 +861,12 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
|
||||
let r = text_rect(t);
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
let all = lines(t);
|
||||
for (i, line) in all.iter().enumerate() {
|
||||
let w = t.family.width(line, t.size, t.bold, t.italic);
|
||||
let w = latin_width(t, line);
|
||||
let x = match t.align {
|
||||
Align::Left | Align::Justify => r[0],
|
||||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||||
@@ -861,6 +937,9 @@ fn params(c: &Content) -> Dict {
|
||||
}),
|
||||
);
|
||||
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
|
||||
if t.char_spacing != 0.0 {
|
||||
d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing));
|
||||
}
|
||||
}
|
||||
Content::Image(i) => {
|
||||
d.set(b"Kind".to_vec(), Object::name("Image"));
|
||||
@@ -904,6 +983,8 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
Some(3) => Align::Justify,
|
||||
_ => Align::Left,
|
||||
},
|
||||
// Items written before character spacing have none.
|
||||
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
}))
|
||||
}
|
||||
b"Image" => {
|
||||
@@ -1004,6 +1085,11 @@ fn validate(c: &Content) -> Result<(), EditError> {
|
||||
if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) {
|
||||
return Err(EditError::Invalid("the font size must be between 1 and 500 points".into()));
|
||||
}
|
||||
if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
|
||||
)));
|
||||
}
|
||||
if (r[2] - r[0]).abs() < 1.0 {
|
||||
return Err(EditError::Invalid("the text box is too narrow".into()));
|
||||
}
|
||||
|
||||
@@ -37,6 +37,28 @@ fn appearance(doc: &Document, d: &Dict) -> Option<ObjRef> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a widget's field has a value: its `/V`, or the nearest one inherited through `/Parent`
|
||||
/// (ISO 32000-2 §12.7.4.1). An empty string or array is no value. Past the depth limit the answer
|
||||
/// is yes: a value that wasn't checked must not be removed.
|
||||
fn has_value(doc: &Document, widget: &Dict) -> bool {
|
||||
let mut d = widget.clone();
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..64 {
|
||||
if let Some(v) = d.get(b"V") {
|
||||
return match &*doc.resolve(v) {
|
||||
Object::Null => false,
|
||||
Object::String(s) => !s.to_text().is_empty(),
|
||||
Object::Array(a) => !a.is_empty(),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
let Some(parent) = d.reference(b"Parent").filter(|p| seen.insert(*p)) else { return false };
|
||||
let Some(next) = doc.get(parent).as_dict().cloned() else { return false };
|
||||
d = next;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Algorithm 8.1: the matrix that maps the form's bounding box onto the annotation rectangle.
|
||||
fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> {
|
||||
let bbox = nums(doc, form.get(b"BBox")).filter(|b| b.len() == 4)?;
|
||||
@@ -220,13 +242,14 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
// left on the page. A widget stays when it has no appearance to draw at all (none
|
||||
// stored, or none for its selected state): removing it would lose the field's value
|
||||
// with nothing in its place (#1794). One whose appearance draws nothing (an empty
|
||||
// stream) shows nothing anywhere, so flattening still removes it.
|
||||
// stream) shows nothing anywhere, so flattening still removes it, and so does one whose
|
||||
// field has no value: there is nothing to lose (#4017).
|
||||
if !drew {
|
||||
let no_appearance = appearance(doc, d).is_none();
|
||||
if no_appearance && let Some(r) = entry.as_ref() {
|
||||
let keep = appearance(doc, d).is_none() && (!widget || has_value(doc, d));
|
||||
if keep && let Some(r) = entry.as_ref() {
|
||||
undrawn.insert(r);
|
||||
}
|
||||
if !widget || no_appearance {
|
||||
if !widget || keep {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +611,55 @@ fn flattening_keeps_a_valued_widget_it_cannot_draw() {
|
||||
assert_eq!(value(6), Some(Object::name("Yes")));
|
||||
}
|
||||
|
||||
/// #4017: a widget with no appearance whose field has no value (none, or an empty string) has
|
||||
/// nothing to lose, so flattening removes it instead of leaving a fillable field behind. One whose
|
||||
/// value it inherits from its parent field still stays (#1794).
|
||||
#[test]
|
||||
fn flattening_removes_an_empty_widget_it_cannot_draw() {
|
||||
let page = stream("", " ");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 9 0 R] >>",
|
||||
&page,
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (no_value) /Rect [10 10 150 40] /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /V () /Rect [10 50 150 80] /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /Rect [10 90 150 120] /F 4 /AP << /N 10 0 R >> >>",
|
||||
"<< /FT /Tx /T (parent) /V (Ada) /Kids [9 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn");
|
||||
let doc = reopen(&doc);
|
||||
let refs = |o: &Object| -> Vec<u32> { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() };
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [9], "only the widget with an inherited value stays on the page");
|
||||
let root = doc.root().unwrap();
|
||||
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [8], "and its field in the form");
|
||||
}
|
||||
|
||||
/// #4017: when the walk up the parent fields reaches its limit before finding a value, the widget
|
||||
/// stays: an unverified value must not be removed. Here `/V` sits on the 64th ancestor.
|
||||
#[test]
|
||||
fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() {
|
||||
let page = stream("", " ");
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [69 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(),
|
||||
page,
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 10 150 40] /F 4 >>".into(),
|
||||
];
|
||||
objs.extend((6..69).map(|n| format!("<< /Parent {} 0 R /Kids [{} 0 R] >>", n + 1, n - 1)));
|
||||
objs.push("<< /FT /Tx /T (deep) /V (Ada) /Kids [68 0 R] >>".into());
|
||||
let mut doc = build(&objs.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
flatten(&mut doc, &[0], false, true).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a).as_array().map_or(0, Vec::len));
|
||||
assert_eq!(annots, Some(1), "the widget stays");
|
||||
}
|
||||
|
||||
/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to
|
||||
/// is flattened; the comment's pop-up still goes with it.
|
||||
#[test]
|
||||
@@ -934,6 +983,52 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
|
||||
assert!(marks_present(&doc).is_empty());
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste (#2636): where a pasted item lands.
|
||||
#[test]
|
||||
fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() {
|
||||
use crate::added::{PASTE_STEP, paste_rect};
|
||||
let page = [300.0, 400.0];
|
||||
let rect = [40.0, 300.0, 140.0, 320.0];
|
||||
assert_eq!(paste_rect(rect, page, 0, None), rect, "in place");
|
||||
let d = 2.0 * PASTE_STEP;
|
||||
assert_eq!(paste_rect(rect, page, 2, None), [40.0 + d, 300.0 - d, 140.0 + d, 320.0 - d], "two steps down and right");
|
||||
assert_eq!(paste_rect(rect, page, 1, Some([10.0, 50.0])), [10.0, 30.0, 110.0, 50.0], "its top-left corner at the point");
|
||||
assert_eq!(paste_rect(rect, page, 0, Some([290.0, 5.0])), [200.0, 0.0, 300.0, 20.0], "pulled back onto the page");
|
||||
assert_eq!(paste_rect([0.0, 0.0, 500.0, 600.0], page, 0, Some([10.0, 10.0])), [0.0, -200.0, 500.0, 400.0], "too large: left and top on the page");
|
||||
assert_eq!(paste_rect([140.0, 320.0, 40.0, 300.0], page, 0, None), rect, "a reversed box is normalized");
|
||||
assert_eq!(paste_rect(rect, [f64::NAN, 400.0], 1, None), rect, "a page without a size leaves the box alone");
|
||||
assert_eq!(paste_rect(rect, page, 1, Some([f64::INFINITY, 5.0])), rect, "so does a point that isn't a number");
|
||||
assert_eq!(paste_rect(rect, page, u32::MAX, None), [200.0, 0.0, 300.0, 20.0], "many pastes stop at the page's corner");
|
||||
}
|
||||
|
||||
/// A paste steps on past exact copies of the item (its original, earlier pastes), and only those.
|
||||
#[test]
|
||||
fn a_paste_steps_past_copies_of_itself_only() {
|
||||
use crate::added::{PASTE_STEP, paste_place};
|
||||
let page = [300.0, 400.0];
|
||||
let text = |rect, t: &str| Content::Text(AddedText { rect, text: t.into(), ..AddedText::default() });
|
||||
let item = text([40.0, 300.0, 140.0, 314.4], "Note");
|
||||
let step = |n: f64| [40.0 + n * PASTE_STEP, 300.0 - n * PASTE_STEP, 140.0 + n * PASTE_STEP, 314.4 - n * PASTE_STEP];
|
||||
assert_eq!(paste_place([], &item, page), step(0.0), "an empty page: where it was");
|
||||
assert_eq!(paste_place([&item], &item, page), step(1.0), "beside its original");
|
||||
let first = item.with_rect(step(1.0));
|
||||
assert_eq!(paste_place([&item, &first], &item, page), step(2.0), "beside the last copy");
|
||||
assert_eq!(paste_place([&first], &item, page), step(0.0), "its place is free again (the original was cut)");
|
||||
let other = text(step(0.0), "Other text");
|
||||
assert_eq!(paste_place([&other], &item, page), step(0.0), "a different item in the way doesn't count");
|
||||
// A page full of copies at its corner: the search ends.
|
||||
let corner = item.with_rect([200.0, 0.0, 300.0, 14.4]);
|
||||
let stuck = paste_place([&corner], &item.with_rect([200.0, 0.0, 300.0, 14.4]), page);
|
||||
assert_eq!(stuck, [200.0, 0.0, 300.0, 14.4]);
|
||||
// An image pasted into another document draws its picture from an object of that document:
|
||||
// still a copy, so the next paste steps past it.
|
||||
let image = Content::Image(AddedImage { crop: [0.1, 0.0, 0.0, 0.0], ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(7, 0)) });
|
||||
let pasted = Content::Image(AddedImage { crop: [0.1, 0.0, 0.0, 0.0], ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(42, 0)) });
|
||||
assert_eq!(paste_place([&pasted], &image, page), step(1.0), "a copy whose picture is stored anew");
|
||||
let turned = Content::Image(AddedImage { rotation: 1, ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(7, 0)) });
|
||||
assert_eq!(paste_place([&turned], &image, page), step(0.0), "the same picture turned or cropped otherwise isn't a copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() {
|
||||
let mut doc = fixture();
|
||||
@@ -1636,6 +1731,214 @@ fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts()
|
||||
assert_eq!(glyphs, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_paragraph_batches_cyrillic_without_changing_source_fonts() {
|
||||
if without_craft_fonts("styled_paragraph_batches_cyrillic_without_changing_source_fonts") {
|
||||
return;
|
||||
}
|
||||
let mut doc = text_page(
|
||||
"BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET",
|
||||
);
|
||||
let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field.";
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(replacement), &text::BlockStyle { width: Some(520.0), ..Default::default() }).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(fallback_fonts(&doc).len(), 1, "the five letters must share one font");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.starts_with("The quick brown fox")), "{lines:?}");
|
||||
let fallback: Vec<_> = lines.iter().filter(|l| l.font.starts_with("PCJp")).collect();
|
||||
assert_eq!(fallback.len(), 1, "one word must be one text run: {fallback:?}");
|
||||
assert_eq!(fallback[0].text, "шалом");
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>().replace(' ', ""), replacement.replace([' ', '\n'], ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically() {
|
||||
let replacement = format!("{}\nLatin", covered_japanese_chars(300));
|
||||
let mut doc = text_page("BT /F1 12 Tf 14.4 TL 72 700 Td (Original) Tj T* (Latin) Tj ET");
|
||||
let original = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
if without_craft_fonts("styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically") {
|
||||
assert!(text::replace_block(&mut doc, 0, 0, &replacement).is_err());
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original);
|
||||
return;
|
||||
}
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&replacement), &text::BlockStyle { width: Some(14000.0), ..Default::default() }).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
let fonts = fallback_fonts(&reopened);
|
||||
assert_eq!(fonts.len(), 2, "300 glyphs need two fonts, not an error at glyph 241");
|
||||
let counts: Vec<_> = fonts.iter().map(|(_, f)| reopened.dict(f.get(b"CharProcs").unwrap()).unwrap().len()).collect();
|
||||
assert_eq!(counts.iter().sum::<usize>(), 300);
|
||||
assert!(counts.iter().all(|n| *n <= 240));
|
||||
assert_eq!(text::text_lines(&reopened, 0).unwrap().iter().map(|l| l.text.as_str()).collect::<String>(), replacement.replace('\n', ""));
|
||||
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let snapshot = doc.clone();
|
||||
let err = text::replace_block(&mut doc, 0, 0, &format!("{replacement}\u{10ffff}")).unwrap_err();
|
||||
assert!(err.to_string().contains("U+10FFFF"), "{err}");
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
|
||||
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_batches_keep_face_choices_and_range_styles() {
|
||||
if without_craft_fonts("styled_fallback_batches_keep_face_choices_and_range_styles") {
|
||||
return;
|
||||
}
|
||||
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
|
||||
eprintln!("skipping: no bold Gothic face");
|
||||
return;
|
||||
}
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET");
|
||||
let range = |range, family, bold| text::RangeStyle {
|
||||
range,
|
||||
family: Some((family, bold, false)),
|
||||
resource: None,
|
||||
size: None,
|
||||
color: None,
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
};
|
||||
let style = text::BlockStyle {
|
||||
width: Some(520.0),
|
||||
ranges: vec![
|
||||
range(3..6, added::Family::Times, false),
|
||||
range(6..9, added::Family::Helvetica, true),
|
||||
range(9..11, added::Family::Times, false),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let original = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let err = text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin\u{10ffff}"), &style).unwrap_err();
|
||||
assert!(err.to_string().contains("U+10FFFF"), "{err}");
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "all faces must validate before writing fallback objects");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin"), &style).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let fonts = fallback_fonts(&doc);
|
||||
let mut summary: Vec<_> = fonts
|
||||
.iter()
|
||||
.map(|(_, f)| {
|
||||
let face = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font;
|
||||
let count = doc.dict(f.get(b"CharProcs").unwrap()).unwrap().len();
|
||||
(face, count)
|
||||
})
|
||||
.collect();
|
||||
summary.sort();
|
||||
assert_eq!(
|
||||
summary,
|
||||
[
|
||||
("BIZUDMincho-Regular".into(), 1),
|
||||
("BIZUDPGothic-Bold".into(), 1),
|
||||
("BIZUDPGothic-Regular".into(), 2),
|
||||
("ShipporiMincho-Regular".into(), 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::<String>(), "見見見ш見шLatin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_budget_is_enforced_before_objects_are_written() {
|
||||
let glyphs = covered_japanese_chars(65);
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET");
|
||||
let original = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let snapshot = doc.clone();
|
||||
let style = text::BlockStyle { width: Some(14000.0), ..Default::default() };
|
||||
let over_budget = format!("{glyphs}\nLatin");
|
||||
let err = text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&over_budget), &style, 1).unwrap_err().to_string();
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "refusal must add no objects");
|
||||
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), original);
|
||||
if without_craft_fonts("styled_fallback_budget_is_enforced_before_objects_are_written") {
|
||||
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
|
||||
return;
|
||||
}
|
||||
assert!(err.contains("more than 64 fallback glyphs"), "{err}");
|
||||
// One glyph per font gives a small, real budget of 64 distinct glyphs. At the
|
||||
// boundary a repeated glyph is free: this is not a limit on text length.
|
||||
let at_budget: String = glyphs.chars().take(64).chain(glyphs.chars().take(1)).collect();
|
||||
text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&format!("{at_budget}\nLatin")), &style, 1).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(fallback_fonts(&doc).len(), 64);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::<String>(), format!("{at_budget}Latin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_same_base_font_and_character_keep_both_weights() {
|
||||
if without_craft_fonts("styled_fallback_same_base_font_and_character_keep_both_weights") {
|
||||
return;
|
||||
}
|
||||
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
|
||||
eprintln!("skipping: no bold Gothic face");
|
||||
return;
|
||||
}
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (alpha ) Tj /F2 12 Tf (beta ) Tj ET");
|
||||
let source = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().unwrap().clone();
|
||||
for (object, weight) in [(5, 400), (6, 700)] {
|
||||
let mut font = source.clone();
|
||||
// The same BaseFont can have different descriptor weights. Changing a
|
||||
// RangeStyle family would also rename BaseFont and miss this cache key.
|
||||
font.set(b"BaseFont".to_vec(), Object::name("NeutralFace"));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontName".to_vec(), Object::name("NeutralFace"));
|
||||
descriptor.set(b"FontWeight".to_vec(), Object::Int(weight));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
doc.set(pdfcraft_cos::ObjRef::new(object, 0), Object::Dict(font));
|
||||
}
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0].base_font, lines[1].base_font);
|
||||
assert_eq!((lines[0].bold, lines[1].bold), (false, true));
|
||||
// Unchanged words anchor each inserted copy of the same letter in its own source style.
|
||||
let block = text::text_blocks(&doc, 0).unwrap().remove(0);
|
||||
let mut inherited = Vec::new();
|
||||
for (range, source) in block.edited_spans("alpha ш beta ш") {
|
||||
for _ in "alpha ш beta ш".get(range).unwrap().chars().filter(|ch| *ch == 'ш') {
|
||||
inherited.push(lines[source].bold);
|
||||
}
|
||||
}
|
||||
assert_eq!(inherited, [false, true], "the fixture must exercise both weights of the same BaseFont");
|
||||
text::replace_block(&mut doc, 0, 0, "alpha ш beta ш").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let fonts = fallback_fonts(&doc);
|
||||
let mut names: Vec<_> = fonts.iter().map(|(_, f)| pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["BIZUDPGothic-Bold", "BIZUDPGothic-Regular"]);
|
||||
let outlines: Vec<_> = fonts
|
||||
.iter()
|
||||
.map(|(_, f)| {
|
||||
let procs = doc.dict(f.get(b"CharProcs").unwrap()).unwrap();
|
||||
assert_eq!(procs.len(), 1, "each weight needs just the shared letter");
|
||||
stream_bytes(&doc, procs.iter().next().unwrap().1).unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert_ne!(outlines[0], outlines[1], "weight must change the outline, not only the descriptor");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>(), "alpha ш beta ш");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_shards_keep_text_advances_and_alignment() {
|
||||
if without_craft_fonts("styled_fallback_shards_keep_text_advances_and_alignment") {
|
||||
return;
|
||||
}
|
||||
let source = "BT /F1 12 Tf 0.5 Tc 5 Tw 80 Tz 14.4 TL 72 700 Td (Original row) Tj T* (Second row) Tj ET";
|
||||
let style =
|
||||
text::BlockStyle { width: Some(520.0), align: Some(added::Align::Center), char_spacing: Some(0.7), scale: Some(80.0), ..Default::default() };
|
||||
let mut whole = text_page(source);
|
||||
let mut split = text_page(source);
|
||||
let replacement = "Latin шалом ш\nSecond row";
|
||||
text::rewrite_block_with_limit(&mut whole, 0, 0, Some(replacement), &style, 240).unwrap();
|
||||
text::rewrite_block_with_limit(&mut split, 0, 0, Some(replacement), &style, 2).unwrap();
|
||||
let (whole, split) = (reopen(&whole), reopen(&split));
|
||||
assert_eq!(fallback_fonts(&whole).len(), 1);
|
||||
assert_eq!(fallback_fonts(&split).len(), 3);
|
||||
let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap());
|
||||
assert_eq!(one.len(), many.len(), "{one:?} vs {many:?}");
|
||||
for (a, b) in one.iter().zip(&many) {
|
||||
assert_eq!(a.text, b.text);
|
||||
for (a, b) in a.rect.iter().zip(b.rect) {
|
||||
assert!((a - b).abs() < 0.002, "sharding must preserve alignment and advances: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the
|
||||
/// document nor a snapshot taken before the edit, which is what undo restores.
|
||||
#[test]
|
||||
@@ -2365,6 +2668,44 @@ fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
|
||||
assert!(text::replace_line(&mut doc, 0, 5, "x").is_err(), "no such line");
|
||||
}
|
||||
|
||||
/// #4280: two paragraphs in subsets without Ç or Ã, one regular and one bold. Each edit that adds
|
||||
/// an accent sets the paragraph in a standard font of its own weight. Editing the bold one must
|
||||
/// not turn the regular one, set in its stand-in earlier, bold too, and the editor's frame for the
|
||||
/// bold one must not reach the regular one.
|
||||
#[test]
|
||||
fn a_bold_stand_in_font_leaves_an_earlier_regular_one_alone() {
|
||||
let widths = vec!["600"; 26].join(" ");
|
||||
let font = |base: &str| {
|
||||
format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} /FirstChar 65 /LastChar 90 /Widths [{widths}] /Encoding /WinAnsiEncoding >>")
|
||||
};
|
||||
let (regular, bold) = (font("ABCDEF+Garamond"), font("ABCDEF+Garamond-Bold"));
|
||||
let content = stream("", "BT /F1 12 Tf 72 700 Td (ACAO) Tj ET BT /F2 12 Tf 300 400 Td (TENSAO) Tj ET");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>",
|
||||
&content,
|
||||
®ular,
|
||||
&bold,
|
||||
]);
|
||||
let block = |doc: &Document, text: &str| text::text_blocks(doc, 0).unwrap().iter().position(|b| b.text == text).unwrap();
|
||||
let acao = block(&doc, "ACAO");
|
||||
assert_eq!(text::replace_block(&mut doc, 0, acao, "AÇÃO").unwrap().substituted.as_deref(), Some("Times-Roman"));
|
||||
let first = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "AÇÃO").unwrap();
|
||||
|
||||
let tensao = block(&doc, "TENSAO");
|
||||
let layout = text::text_edit_layout(&doc, 0, tensao, "TENSÃO", &text::BlockStyle::default()).unwrap();
|
||||
let draft = layout.draft_rect.unwrap();
|
||||
assert!(draft[3] < first.rect[1] || draft[1] > first.rect[3], "the frame {draft:?} reaches the regular paragraph at {:?}", first.rect);
|
||||
assert_eq!(text::replace_block(&mut doc, 0, tensao, "TENSÃO").unwrap().substituted.as_deref(), Some("Times-Bold"));
|
||||
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let font_of = |text: &str| lines.iter().find(|l| l.text == text).map(|l| l.base_font.clone());
|
||||
assert_eq!(font_of("AÇÃO").as_deref(), Some("Times-Roman"), "{lines:?}");
|
||||
assert_eq!(font_of("TENSÃO").as_deref(), Some("Times-Bold"), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_are_found_and_rewrapped() {
|
||||
let mut doc = text_page(
|
||||
@@ -6356,6 +6697,24 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given
|
||||
/// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when
|
||||
/// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the
|
||||
/// file (`Tf`, or a small `Tf` scaled up by `Tm`) or asked for (the automation tools pass it through).
|
||||
#[test]
|
||||
fn width_on_a_paragraph_set_larger_than_a_page_is_capped_not_a_panic() {
|
||||
let wide = text::BlockStyle { width: Some(200.0), ..Default::default() };
|
||||
let huge = text::BlockStyle { size: Some(20_000.0), width: Some(200.0), ..Default::default() };
|
||||
for (content, style) in [
|
||||
("BT /F2 20000 Tf 72 700 Td (ab) Tj ET", &wide),
|
||||
("BT /F2 1 Tf 20000 0 0 20000 72 700 Tm (ab) Tj ET", &wide),
|
||||
("BT /F2 12 Tf 72 700 Td (ab) Tj ET", &huge),
|
||||
] {
|
||||
let mut doc = text_page(content);
|
||||
text::rewrite_block(&mut doc, 0, 0, None, style).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_text_is_wrapped_once_per_write() {
|
||||
let mut doc = fixture();
|
||||
@@ -6367,6 +6726,35 @@ fn added_text_is_wrapped_once_per_write() {
|
||||
assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines");
|
||||
}
|
||||
|
||||
/// Character spacing on added text (#4245) is the PDF's `Tc`, kept in `/PCAdded` so the item
|
||||
/// reopens with it; an item written without it reads 0.
|
||||
#[test]
|
||||
fn added_text_character_spacing_is_drawn_and_reopens() {
|
||||
let mut doc = fixture();
|
||||
let plain = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "Approved".into(), size: 10.0, ..AddedText::default() };
|
||||
add_content(&mut doc, 0, &Content::Text(plain.clone())).unwrap();
|
||||
add_content(&mut doc, 0, &Content::Text(AddedText { char_spacing: 2.5, ..plain })).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let spacings: Vec<f64> = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.char_spacing } else { f64::NAN }).collect();
|
||||
assert_eq!(spacings, [0.0, 2.5]);
|
||||
let page = streams(&doc, 0);
|
||||
let tc: Vec<bool> = page.iter().filter(|s| s.contains("(Approved) Tj")).map(|s| s.contains("2.5 Tc")).collect();
|
||||
assert_eq!(tc, [false, true], "{page:?}");
|
||||
}
|
||||
|
||||
/// Spacing widens every measure the layout takes: the widest line and wrapping.
|
||||
#[test]
|
||||
fn added_text_character_spacing_widens_its_layout() {
|
||||
let plain = AddedText { rect: [0.0, 0.0, 1000.0, 100.0], text: "Approved".into(), size: 10.0, ..AddedText::default() };
|
||||
let spaced = AddedText { char_spacing: 3.0, ..plain.clone() };
|
||||
// After each of the 8 letters, like `Tc`.
|
||||
assert!((added::text_width(&spaced) - added::text_width(&plain) - 24.0).abs() < 1e-9);
|
||||
let w = added::text_width(&plain);
|
||||
let narrow = |t: &AddedText| AddedText { text: "Approved Approved".into(), rect: [0.0, 0.0, 2.0 * w + 5.0, 100.0], ..t.clone() };
|
||||
assert_eq!(added::lines(&narrow(&plain)).len(), 1);
|
||||
assert_eq!(added::lines(&narrow(&spaced)).len(), 2, "the spaced words no longer fit one line");
|
||||
}
|
||||
|
||||
/// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not
|
||||
/// in its paths, and a node listing itself is visited once.
|
||||
#[test]
|
||||
@@ -6571,3 +6959,419 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() {
|
||||
assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_))));
|
||||
assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content");
|
||||
}
|
||||
|
||||
/// How each glyph-showing operator on page 0 is painted, read the way a renderer reads the page:
|
||||
/// its text, `Tr`, stroke colour operators, line width (from `w` or an ExtGState's `/LW`) and
|
||||
/// the ExtGStates selected before it, following `q`/`Q`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Painted {
|
||||
text: String,
|
||||
render_mode: i64,
|
||||
stroke: Vec<String>,
|
||||
line_width: f64,
|
||||
gs: Vec<String>,
|
||||
}
|
||||
|
||||
fn painted_shows(doc: &Document) -> Vec<Painted> {
|
||||
let (_, ops) = joined_ops(doc);
|
||||
let p = &pdfcraft_model::pages(doc)[0];
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let ext = res.as_dict().unwrap().get(b"ExtGState").map(|g| doc.resolve(g).as_dict().cloned().unwrap()).unwrap_or_default();
|
||||
let mut state = Painted { text: String::new(), render_mode: 0, stroke: Vec::new(), line_width: 1.0, gs: Vec::new() };
|
||||
let mut stack = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let show = |op: &pdfcraft_content::Op| String::from_utf8_lossy(&pdfcraft_content::serialize_ops(std::slice::from_ref(op))).trim().to_string();
|
||||
for op in ops {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(state.clone()),
|
||||
b"Q" => state = stack.pop().unwrap(),
|
||||
b"Tr" => state.render_mode = op.operands[0].as_int().unwrap(),
|
||||
b"G" | b"RG" | b"K" | b"CS" => state.stroke = vec![show(&op)],
|
||||
b"SC" | b"SCN" => state.stroke.push(show(&op)),
|
||||
b"w" => state.line_width = op.num(0).unwrap(),
|
||||
b"gs" => {
|
||||
let name = op.name(0).unwrap();
|
||||
state.gs.push(String::from_utf8_lossy(name).into_owned());
|
||||
let lw = ext.get(name).and_then(|g| doc.resolve(g).as_dict().and_then(|g| g.get(b"LW")).and_then(|w| w.as_f64()));
|
||||
state.line_width = lw.unwrap_or(state.line_width);
|
||||
}
|
||||
b"Tj" => {
|
||||
let Some(Object::String(s)) = op.operands.last() else { continue };
|
||||
if !s.bytes.is_empty() {
|
||||
out.push(Painted { text: String::from_utf8_lossy(&s.bytes).into_owned(), ..state.clone() });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A page with Helvetica as /F1, /GS1, an ExtGState that paints at 20 % opacity, /GS2, one that
|
||||
/// sets the line width to 4, /GS3, another at 20 % opacity, and /GS4, at 20 % opacity and dashed.
|
||||
fn faint_page(content: &str) -> Document {
|
||||
build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /ExtGState << /GS1 6 0 R /GS2 7 0 R /GS3 8 0 R /GS4 9 0 R >> >> >>",
|
||||
&format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
"<< /Type /ExtGState /ca 0.2 >>",
|
||||
"<< /Type /ExtGState /LW 4 >>",
|
||||
"<< /Type /ExtGState /ca 0.2 >>",
|
||||
"<< /Type /ExtGState /ca 0.2 /D [[6 3] 0] >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// Rewriting a paragraph drew it before its text object, restating only the font, spacing and
|
||||
/// fill: a `1 Tr 1 0 0 RG 2 w` set inside `BT` was lost, and red outline text came back filled
|
||||
/// black, even with the text unchanged (#3508). Set before `BT` (the control), it already worked.
|
||||
#[test]
|
||||
fn a_rewritten_paragraph_keeps_the_rendering_mode_stroke_and_width_set_inside_its_text_object() {
|
||||
let outline = |text: &str| Painted { text: text.into(), render_mode: 1, stroke: vec!["1 0 0 RG".into()], line_width: 2.0, gs: Vec::new() };
|
||||
for content in ["BT /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (OUTLINE) Tj ET\n", "1 Tr 1 0 0 RG 2 w BT /F1 72 Tf 40 400 Td (OUTLINE) Tj ET\n"] {
|
||||
assert_eq!(painted_shows(&text_page(content)), [outline("OUTLINE")], "the fixture: {content}");
|
||||
for new in ["OUTLINE", "OUTLINED"] {
|
||||
let mut doc = text_page(content);
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted_shows(&doc), [outline(new)], "{content} -> {new}: {}", streams(&doc, 0).join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for transparency: `/GS1 gs` (`/ca 0.2`) inside `BT` was not in force for the
|
||||
/// rewritten paragraph, so 20 % blue text came back opaque (#3509).
|
||||
#[test]
|
||||
fn a_rewritten_paragraph_keeps_the_extgstate_selected_inside_its_text_object() {
|
||||
for content in ["BT /GS1 gs /F1 72 Tf 0 0 1 rg 40 400 Td (FAINT) Tj ET\n", "/GS1 gs BT /F1 72 Tf 0 0 1 rg 40 400 Td (FAINT) Tj ET\n"] {
|
||||
for new in ["FAINT", "FAINTER"] {
|
||||
let mut doc = faint_page(content);
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let shows = painted_shows(&doc);
|
||||
assert_eq!(shows.len(), 1, "{content} -> {new}: {shows:?}");
|
||||
assert_eq!(
|
||||
(shows[0].text.as_str(), shows[0].gs.as_slice()),
|
||||
(new, ["GS1".to_string()].as_slice()),
|
||||
"{content} -> {new}: {}",
|
||||
streams(&doc, 0).join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph of differently coloured runs goes through the styled rewrite, which draws each
|
||||
/// run in its own text object: each keeps the state its source set inside `BT`.
|
||||
#[test]
|
||||
fn a_styled_rewrite_keeps_the_paint_state_set_inside_the_text_object() {
|
||||
let content = "BT /GS1 gs 1 Tr 1 0 0 RG 2 w /F1 72 Tf 40 400 Td (FAI) Tj 0 0 1 rg (NT) Tj ET\n";
|
||||
let mut doc = faint_page(content);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some("FAINTS"), &BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let shows = painted_shows(&doc);
|
||||
let joined = streams(&doc, 0).join("\n");
|
||||
assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::<String>(), "FAINTS", "{joined}");
|
||||
for s in &shows {
|
||||
assert_eq!((s.render_mode, s.stroke.as_slice(), s.line_width), (1, ["1 0 0 RG".to_string()].as_slice(), 2.0), "{s:?}: {joined}");
|
||||
assert_eq!(s.gs, ["GS1"], "{s:?}: {joined}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `gs` has no inverse, so one run's ExtGState stayed in force for the runs the styled rewrite
|
||||
/// drew after it. Two ExtGStates at the same opacity paint the same (see `Paint`), so their runs
|
||||
/// share a paragraph, but either may set what that doesn't compare (a dash, a line cap): each run
|
||||
/// is drawn with only the ones its source selected.
|
||||
#[test]
|
||||
fn a_styled_rewrite_does_not_carry_one_runs_extgstate_into_the_next() {
|
||||
// "FAI" ends at 40 + 72 * (0.611 + 0.667 + 0.278) = 152.032; "NT" is blue, so another run.
|
||||
let content = "q BT /GS3 gs /F1 72 Tf 40 400 Td (FAI) Tj ET Q BT /GS1 gs /F1 72 Tf 0 0 1 rg 152.032 400 Td (NT) Tj ET
|
||||
";
|
||||
for new in ["FAINT", "FAINTS"] {
|
||||
let mut doc = faint_page(content);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let shows = painted_shows(&doc);
|
||||
let joined = streams(&doc, 0).join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::<String>(), new, "{joined}");
|
||||
let gs: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.gs.as_slice())).collect();
|
||||
assert_eq!(gs.first(), Some(&("FAI", ["GS3".to_string()].as_slice())), "{gs:?}: {joined}");
|
||||
assert!(gs.iter().skip(1).all(|(_, gs)| *gs == ["GS1"]), "{gs:?}: {joined}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An ExtGState's `/LW` sets the line width as `w` does: `2 w /GS2 gs` (`/LW 4`) outlines at 4,
|
||||
/// and the rewrite replayed `/GS2 gs` and then `2 w`. `/GS2 gs 1 w` outlines at 1, and the
|
||||
/// rewrite replayed only `/GS2 gs`, since 1 is the initial width.
|
||||
#[test]
|
||||
fn a_rewritten_paragraph_keeps_the_line_width_an_extgstate_set() {
|
||||
for (content, width) in [
|
||||
("BT /F1 72 Tf 1 Tr 2 w /GS2 gs 40 400 Td (OUTLINE) Tj ET\n", 4.0),
|
||||
("BT /F1 72 Tf 1 Tr /GS2 gs 2 w 40 400 Td (OUTLINE) Tj ET\n", 2.0),
|
||||
("BT /F1 72 Tf 1 Tr /GS2 gs 1 w 40 400 Td (OUTLINE) Tj ET\n", 1.0),
|
||||
] {
|
||||
assert_eq!(painted_shows(&faint_page(content))[0].line_width, width, "the fixture: {content}");
|
||||
for new in ["OUTLINE", "OUTLINED"] {
|
||||
let mut doc = faint_page(content);
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let shows = painted_shows(&doc);
|
||||
assert_eq!(
|
||||
shows.iter().map(|s| (s.text.as_str(), s.render_mode, s.line_width)).collect::<Vec<_>>(),
|
||||
[(new, 1, width)],
|
||||
"{content} -> {new}: {}",
|
||||
streams(&doc, 0).join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line is rewritten in the state of its first run, so outline text partway through a text
|
||||
/// object came back filled: "FAI" filled and "NT" a red outline came back all filled. The change
|
||||
/// of `Tr` now starts a run. (A `gs` partway already starts a paragraph; see `Paint`.)
|
||||
#[test]
|
||||
fn a_rendering_mode_partway_through_a_line_starts_a_run_of_its_own() {
|
||||
let content = "BT /F1 72 Tf 40 400 Td (FAI) Tj 1 Tr 1 0 0 RG 2 w (NT) Tj ET
|
||||
";
|
||||
for new in ["FAINT", "FAINTS"] {
|
||||
let mut doc = faint_page(content);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let shows = painted_shows(&doc);
|
||||
let joined = streams(&doc, 0).join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::<String>(), new, "{joined}");
|
||||
let modes: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.render_mode, s.stroke.as_slice(), s.line_width)).collect();
|
||||
assert_eq!(modes.first().map(|m| (m.0, m.1)), Some(("FAI", 0)), "{modes:?}: {joined}");
|
||||
let outline = ["1 0 0 RG".to_string()];
|
||||
assert!(modes.iter().skip(1).all(|m| (m.1, m.2, m.3) == (1, outline.as_slice(), 2.0)), "{modes:?}: {joined}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two ExtGStates at the same opacity paint text the same (see `Paint`), but one of them dashes
|
||||
/// its outline: a dashed outline after a solid one in the same text object joined its line and
|
||||
/// came back solid, drawn with the first run's selections. A different dash now starts a run.
|
||||
#[test]
|
||||
fn a_dash_partway_through_an_outlined_line_starts_a_run_of_its_own() {
|
||||
let content = "BT /GS3 gs /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (FAI) Tj /GS4 gs (NT) Tj ET
|
||||
";
|
||||
for new in ["FAINT", "FAINTS"] {
|
||||
let mut doc = faint_page(content);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let shows = painted_shows(&doc);
|
||||
let joined = streams(&doc, 0).join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::<String>(), new, "{joined}");
|
||||
let gs: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.gs.as_slice())).collect();
|
||||
assert_eq!(gs.first(), Some(&("FAI", ["GS3".to_string()].as_slice())), "{gs:?}: {joined}");
|
||||
assert!(gs.iter().skip(1).all(|(_, gs)| gs.last().is_some_and(|g| g == "GS4")), "{gs:?}: {joined}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The dash and cap are compared as the operators set them too: `d` or `J` partway through an
|
||||
/// outlined line starts a run, and a malformed one (too long, not numbers, out of range) is ignored.
|
||||
#[test]
|
||||
fn a_dash_or_cap_operator_partway_through_an_outlined_line_starts_a_run() {
|
||||
let long = format!("[{}] 0 d", "1 ".repeat(40));
|
||||
for (change, runs) in [("[6 3] 0 d", 2), ("1 J", 2), (long.as_str(), 1), ("[(a) 3] 0 d", 1), ("[6 3] /x d", 1), ("7 J", 1), ("J", 1)] {
|
||||
let content = format!("BT /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (FAI) Tj {change} (NT) Tj ET\n");
|
||||
let mut doc = faint_page(&content);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), runs, "{change}");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some("FAINTS"), &BlockStyle::default()).unwrap();
|
||||
let shows = painted_shows(&reopen(&doc));
|
||||
assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::<String>(), "FAINTS", "{change}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #4199, #3797: every space typed into a paragraph is saved, not folded into one.
|
||||
#[test]
|
||||
fn an_edited_paragraph_keeps_every_space_typed() {
|
||||
for typed in ["Hello world", "Hello world end", " indented and trailing "] {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
assert_eq!(text::replace_block(&mut doc, 0, 0, typed).unwrap().substituted, None);
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains(&format!("({typed}) Tj")), "{typed:?}: {content}");
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, typed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaces after a row's last word are kept but take no part in its alignment: a justified row
|
||||
/// still reaches the margin, and a centred or right-aligned one sits where it would without them.
|
||||
#[test]
|
||||
fn spaces_after_a_rows_last_word_do_not_move_its_alignment() {
|
||||
let shown = |text: &str, align: added::Align| {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
let style =
|
||||
text::BlockStyle { family: Some((added::Family::Helvetica, false, false)), align: Some(align), width: Some(300.0), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(text), &style).unwrap();
|
||||
String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned()
|
||||
};
|
||||
for (plain, trailing, align) in [
|
||||
("aaa bbb ccc\nddd eee", "aaa bbb ccc \nddd eee", added::Align::Justify),
|
||||
("Title", "Title ", added::Align::Center),
|
||||
("Title", "Title ", added::Align::Right),
|
||||
] {
|
||||
let first = trailing.split('\n').next().unwrap();
|
||||
let expected = shown(plain, align);
|
||||
let actual = shown(trailing, align).replace(&format!("({first})"), &format!("({})", first.trim_end()));
|
||||
assert_eq!(actual, expected, "{trailing:?} {align:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A justified row runs from the paragraph's left edge to the margin. Word spacing reaches only
|
||||
/// the spaces between its words: the spaces before its first word and after its last are left
|
||||
/// out of a stretched row (as before #4199), so the reopened rows stay inside the paragraph and
|
||||
/// can be edited again in place.
|
||||
#[test]
|
||||
fn a_justified_row_keeps_its_indent_and_ends_at_the_margin() {
|
||||
let style = text::BlockStyle {
|
||||
family: Some((added::Family::Helvetica, false, false)),
|
||||
align: Some(added::Align::Justify),
|
||||
width: Some(300.0),
|
||||
..Default::default()
|
||||
};
|
||||
for typed in [" Pack my box with five dozen jugs\nof liquor", " Pack my box with five dozen jugs \nof liquor"] {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(typed), &style).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let first = lines.iter().find(|l| l.text.contains("Pack")).unwrap();
|
||||
assert_eq!(first.text, "Pack my box with five dozen jugs", "{typed:?}");
|
||||
assert!((first.rect[0] - 72.0).abs() < 0.5, "{typed:?}: {:?}", first.rect);
|
||||
assert!(lines.iter().all(|l| l.rect[0] >= 72.0 - 0.5 && l.rect[2] <= 372.5), "{typed:?}: {lines:?}");
|
||||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||||
assert_eq!(blocks.len(), 1, "{typed:?}: {blocks:?}");
|
||||
text::replace_block(&mut doc, 0, 0, "Edited again").unwrap();
|
||||
let again = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
assert!(again.iter().all(|l| l.rect[0] >= 0.0 && l.rect[2] <= 600.0), "{typed:?}: {again:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A justified paragraph whose first row was typed with an indent is edited again through the
|
||||
/// styled path (a draft with a line break): its rows keep their scaling and stay one paragraph
|
||||
/// inside its width.
|
||||
#[test]
|
||||
fn a_justified_paragraph_with_an_indent_edits_again_at_its_own_scale() {
|
||||
let style = text::BlockStyle {
|
||||
family: Some((added::Family::Helvetica, false, false)),
|
||||
align: Some(added::Align::Justify),
|
||||
width: Some(300.0),
|
||||
..Default::default()
|
||||
};
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(" aaa bbb ccc \nddd"), &style).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let draft = text::text_blocks(&doc, 0).unwrap()[0].editor_text.replacen("ccc", "cc", 1);
|
||||
assert!(draft.contains('\n'), "{draft:?}");
|
||||
text::replace_block(&mut doc, 0, 0, &draft).unwrap();
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let scales: Vec<&str> = content.lines().filter(|l| l.ends_with(" Tz")).collect();
|
||||
assert!(scales.iter().all(|l| *l == "100 Tz"), "{scales:?}: {content}");
|
||||
let doc = reopen(&doc);
|
||||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||||
assert_eq!(blocks.len(), 1, "{blocks:?}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert!(lines.iter().all(|l| l.rect[0] >= 72.0 - 0.5 && l.rect[2] <= 372.5), "{lines:?}");
|
||||
}
|
||||
|
||||
/// A paragraph drawn at `0 Tz` has no width to justify into: nothing is divided by it. The
|
||||
/// writer writes a number that isn't finite as 0, so the test looks for the word spacing and
|
||||
/// the move a division by zero would add, rather than for `NaN`.
|
||||
#[test]
|
||||
fn justifying_text_at_zero_horizontal_scaling_divides_by_nothing() {
|
||||
let mut doc = text_page("BT /F1 12 Tf 0 Tz 72 700 Td (Hello world) Tj ET");
|
||||
let style = text::BlockStyle { family: Some((added::Family::Helvetica, false, false)), align: Some(added::Align::Justify), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(" aaa bbb\nccc"), &style).unwrap();
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let block = content.split("ET").next().unwrap();
|
||||
assert_eq!(block.matches(" Tw").count(), 1, "only the paragraph's own 0 Tw: {content}");
|
||||
let first_row = block.split(" Tm").nth(1).and_then(|rest| rest.split(" Tj").next()).unwrap();
|
||||
assert!(!first_row.contains(" Td"), "no move before the first row: {content}");
|
||||
}
|
||||
|
||||
/// The preview's box is the box of the rows Apply writes, measured to their last glyphs.
|
||||
#[test]
|
||||
fn a_drafts_box_is_the_box_apply_draws() {
|
||||
let doc = text_page("BT /F1 12 Tf 14 TL 72 700 Td [(First) -2000 (row here )] TJ T* (Second row ) Tj ET");
|
||||
let source = text::text_blocks(&doc, 0).unwrap().swap_remove(0);
|
||||
let draft = source.editor_text.replacen("Second", "Third", 1);
|
||||
let layout = text::text_edit_layout(&doc, 0, 0, &draft, &text::BlockStyle::default()).unwrap();
|
||||
let mut applied = doc.clone();
|
||||
text::replace_block(&mut applied, 0, 0, &draft).unwrap();
|
||||
let boxed = text::text_lines(&applied, 0)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let end = l.ink.iter().map(|r| r[2]).fold(f64::NEG_INFINITY, f64::max);
|
||||
[l.rect[0], l.rect[1], if end.is_finite() { end.min(l.rect[2]) } else { l.rect[2] }, l.rect[3]]
|
||||
})
|
||||
.reduce(|a, r| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]);
|
||||
let (d, b) = (layout.draft_rect.unwrap(), boxed.unwrap());
|
||||
assert!(d.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01), "{draft:?}: preview {d:?}, applied {b:?}");
|
||||
}
|
||||
|
||||
/// The draft's box is measured to its last glyph, not over the spaces typed after it.
|
||||
#[test]
|
||||
fn a_drafts_box_leaves_out_spaces_after_the_last_word() {
|
||||
let doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
let layout = |text: &str| text::text_edit_layout(&doc, 0, 0, text, &text::BlockStyle::default()).unwrap().draft_rect;
|
||||
assert_eq!(layout("Hello there "), layout("Hello there"));
|
||||
}
|
||||
|
||||
/// A lone carriage return (an old Mac line ending) breaks the row, as `\n` and `\r\n` do.
|
||||
#[test]
|
||||
fn a_lone_carriage_return_breaks_an_edited_paragraph() {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
text::replace_block(&mut doc, 0, 0, "one\rtwo").unwrap();
|
||||
let lines: Vec<String> = text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(lines, ["one", "two"]);
|
||||
}
|
||||
|
||||
/// On the styled rewrite path too, `\r\n` is one row break and a lone `\r` another, and no
|
||||
/// carriage return reaches the page: with no other formatting, and with a part set in red.
|
||||
#[test]
|
||||
fn carriage_returns_break_rows_on_the_styled_path() {
|
||||
let draft = "one\r\ntwo\rthree";
|
||||
let red = text::RangeStyle {
|
||||
range: 0..3,
|
||||
family: None,
|
||||
resource: None,
|
||||
size: None,
|
||||
color: Some([1.0, 0.0, 0.0]),
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
};
|
||||
for style in [text::BlockStyle::default(), text::BlockStyle { ranges: vec![red], ..Default::default() }] {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = page_content_bytes(&doc, 0);
|
||||
assert!(!content.contains(&b'\r') && !content.windows(2).any(|w| w == b"\\r"), "{}", String::from_utf8_lossy(&content));
|
||||
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(lines, ["one", "two", "three"], "{style:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A subset with no space glyph shows each space as a positioning gap one space wide, so a run
|
||||
/// of spaces still takes a run's width (#4199).
|
||||
#[test]
|
||||
fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
|
||||
assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba ab").unwrap().substituted, None);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
|
||||
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
|
||||
}
|
||||
+316
-57
@@ -80,6 +80,10 @@ pub struct TextLine {
|
||||
/// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read,
|
||||
/// so which way its glyphs advance is unknown. The line is listed as horizontal text.
|
||||
unreadable_mode: Option<String>,
|
||||
/// The runs the line joins, in content order: each one's text, the end of its words in
|
||||
/// `ink`, and whether joining put a space before it. Empty when the line's text was put
|
||||
/// into another order (see [`TextLine::runs`]).
|
||||
runs: Vec<(String, usize, bool)>,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
@@ -204,7 +208,17 @@ fn layout_draft(
|
||||
*count -= 1;
|
||||
continue;
|
||||
}
|
||||
rect = Some(rect.map_or(line.rect, |r| [r[0].min(line.rect[0]), r[1].min(line.rect[1]), r[2].max(line.rect[2]), r[3].max(line.rect[3])]));
|
||||
// Spaces typed after a row's last word are drawn but show nothing, so an upright row is
|
||||
// measured to the end of its last word: they don't make a draft overflow (#4199).
|
||||
let mut r = line.rect;
|
||||
if line.upright() && !line.ink.is_empty() {
|
||||
if crate::bidi::rtl_base(&line.text) {
|
||||
r[0] = r[0].max(line.ink.iter().map(|i| i[0]).fold(f64::INFINITY, f64::min));
|
||||
} else {
|
||||
r[2] = r[2].min(line.ink.iter().map(|i| i[2]).fold(f64::NEG_INFINITY, f64::max));
|
||||
}
|
||||
}
|
||||
rect = Some(rect.map_or(r, |a| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]));
|
||||
}
|
||||
let overflow = rect
|
||||
.is_some_and(|r| r[0] < source.rect[0] - 0.5 || r[1] < source.rect[1] - 0.5 || r[2] > source.rect[2] + 0.5 || r[3] > source.rect[3] + 0.5);
|
||||
@@ -223,6 +237,14 @@ struct TextState {
|
||||
fill: Vec<Op>,
|
||||
/// The text rendering mode (`Tr`); 3 is invisible text, as in an OCR layer over a scan.
|
||||
render_mode: i64,
|
||||
/// The stroke colour operators, the line width and the ExtGStates selected (see [`Ts`]):
|
||||
/// graphics state the text was painted with that [`TextState::ops`] doesn't restate.
|
||||
stroke: Vec<Op>,
|
||||
line_width: Option<f64>,
|
||||
ext_gstate: Vec<Vec<u8>>,
|
||||
/// The dash pattern and line cap in force (see [`Ts`]): compared, not restated.
|
||||
dash: Dash,
|
||||
line_cap: i64,
|
||||
}
|
||||
|
||||
impl TextState {
|
||||
@@ -236,9 +258,53 @@ impl TextState {
|
||||
rise: ts.rise,
|
||||
fill: ts.fill.clone(),
|
||||
render_mode: ts.render_mode,
|
||||
stroke: ts.stroke.clone(),
|
||||
line_width: ts.line_width,
|
||||
ext_gstate: ts.ext_gstate.clone(),
|
||||
dash: ts.dash.clone(),
|
||||
line_cap: ts.line_cap,
|
||||
}
|
||||
}
|
||||
|
||||
/// Operators that change how text is painted from `current` (the state where they run) to
|
||||
/// this one: the ExtGStates, stroke colour, line width and a visible rendering mode. A
|
||||
/// rewritten paragraph is drawn before its text object, so whatever that object set before
|
||||
/// its first show (`/GS1 gs`, `1 Tr 1 0 0 RG 2 w` inside `BT`, #3508/#3509) must be stated
|
||||
/// again. Empty when nothing differs.
|
||||
///
|
||||
/// Only fill, stroke and fill-then-stroke (`Tr` 0–2) are restated: invisible text is revealed
|
||||
/// by [`reveal_ops`] and clipping text is refused, so neither is ever drawn this way.
|
||||
///
|
||||
/// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force:
|
||||
/// run these inside a `q`/`Q` that starts from `current`, one per state drawn.
|
||||
fn paint_ops(&self, current: &TextState) -> Vec<Op> {
|
||||
let n = pdfcraft_content::num;
|
||||
let mut v = Vec::new();
|
||||
// The selections both share stay in force; the ones after them are selected again in order.
|
||||
let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count();
|
||||
for name in self.ext_gstate.iter().skip(shared) {
|
||||
v.push(Op::new("gs", vec![Object::Name(name.clone())]));
|
||||
}
|
||||
// A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it.
|
||||
let reselected = !v.is_empty();
|
||||
if self.stroke != current.stroke {
|
||||
if self.stroke.is_empty() {
|
||||
// The initial stroke colour: black in DeviceGray.
|
||||
v.push(Op::new("G", vec![n(0.0)]));
|
||||
} else {
|
||||
v.extend(self.stroke.iter().cloned());
|
||||
}
|
||||
}
|
||||
let width = self.line_width.unwrap_or(1.0);
|
||||
if reselected || width != current.line_width.unwrap_or(1.0) {
|
||||
v.push(Op::new("w", vec![n(width)]));
|
||||
}
|
||||
if (0..=2).contains(&self.render_mode) && self.render_mode != current.render_mode {
|
||||
v.push(Op::new("Tr", vec![Object::Int(self.render_mode)]));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Operators that set this state (inside a text object).
|
||||
fn ops(&self) -> Vec<Op> {
|
||||
let n = pdfcraft_content::num;
|
||||
@@ -530,12 +596,27 @@ impl TextLine {
|
||||
self.origin.baseline
|
||||
}
|
||||
|
||||
/// Paint position of the line's last text-showing operator: `(content-stream index,
|
||||
/// operator index in the page's joined stream)`. Lines paint in order of this tuple.
|
||||
/// Paint position of the line's first operator: `(content-stream index, operator index in
|
||||
/// the page's joined stream)`. A line joins runs painted at different times, so anything
|
||||
/// painted after this position may lie on top of part of it.
|
||||
/// (Lines from `text_lines` never come from form XObjects, so `stream` is always a
|
||||
/// page content-stream index and `ops` are indices into the joined operator list.)
|
||||
pub fn paint_pos(&self) -> (usize, usize) {
|
||||
(self.stream, self.ops.iter().max().copied().unwrap_or(0))
|
||||
pub fn first_paint_pos(&self) -> (usize, usize) {
|
||||
(self.stream, self.ops.iter().min().copied().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The runs of text the line joins, in content order: each run's text as its glyphs show it,
|
||||
/// the boxes of its words (a part of `ink`), and whether joining put a space between it and
|
||||
/// the run before (`text` has one there; runs closer together join with none, so one word
|
||||
/// can span runs). None when the line's text was reordered for reading (right-to-left or
|
||||
/// visual-order Indic text): then only the line's text as a whole says what it reads.
|
||||
pub fn runs(&self) -> impl Iterator<Item = (&str, &[[f64; 4]], bool)> {
|
||||
let mut start = 0;
|
||||
self.runs.iter().map(move |(text, end, spaced)| {
|
||||
let ink = self.ink.get(start..*end).unwrap_or_default();
|
||||
start = *end;
|
||||
(text.as_str(), ink, *spaced)
|
||||
})
|
||||
}
|
||||
|
||||
fn rotation(&self) -> f64 {
|
||||
@@ -615,6 +696,50 @@ struct Ts {
|
||||
/// Why the font's writing mode couldn't be read (a broken or cyclic CMap). Its text is then
|
||||
/// listed as horizontal, but the lines in it are refused for editing.
|
||||
unreadable: Option<Rc<str>>,
|
||||
/// The operators that set the current stroke colour (`G`, `RG`, `K`, or `CS` + `SC`/`SCN`).
|
||||
stroke: Vec<Op>,
|
||||
/// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0).
|
||||
line_width: Option<f64>,
|
||||
/// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]).
|
||||
ext_gstate: Vec<Vec<u8>>,
|
||||
/// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`).
|
||||
dash: Dash,
|
||||
/// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt).
|
||||
line_cap: i64,
|
||||
}
|
||||
|
||||
/// A dash pattern (ISO 32000-2 §8.4.3.6): the dash array and the phase.
|
||||
type Dash = (Vec<f64>, f64);
|
||||
|
||||
/// The most dash array entries a pattern keeps; a longer one is ignored, as is a malformed one.
|
||||
const MAX_DASH: usize = 32;
|
||||
|
||||
/// The dash pattern `array phase`, as `d` takes it and an ExtGState's `/D` holds it.
|
||||
fn dash_pattern(doc: &Document, array: Option<&Object>, phase: Option<&Object>) -> Option<Dash> {
|
||||
let array = doc.resolve(array?);
|
||||
let items = array.as_array().filter(|a| a.len() <= MAX_DASH)?;
|
||||
let finite = |o: &Object| doc.resolve(o).as_f64().filter(|v| v.is_finite());
|
||||
Some((items.iter().map(finite).collect::<Option<Vec<_>>>()?, finite(phase?)?))
|
||||
}
|
||||
|
||||
/// A line cap style, as `J` takes it and an ExtGState's `/LC` holds it: 0, 1 or 2.
|
||||
fn line_cap(doc: &Document, cap: Option<&Object>) -> Option<i64> {
|
||||
doc.resolve(cap?).as_int().filter(|c| (0..=2).contains(c))
|
||||
}
|
||||
|
||||
/// The most `gs` selections a state remembers. Repeating a selection moves it to the end, so
|
||||
/// this bounds the distinct ExtGStates in force, not the operators a hostile stream repeats.
|
||||
const MAX_GS: usize = 32;
|
||||
|
||||
/// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute
|
||||
/// value, so a later selection of the same dictionary overrides the earlier one completely: the
|
||||
/// earlier entry goes, which keeps replaying the list equivalent to replaying the stream.
|
||||
fn select_gs(list: &mut Vec<Vec<u8>>, name: &[u8]) {
|
||||
list.retain(|n| n != name);
|
||||
if list.len() >= MAX_GS {
|
||||
list.remove(0);
|
||||
}
|
||||
list.push(name.to_vec());
|
||||
}
|
||||
|
||||
/// The page's content streams as listed, each with its decoded data (empty when it doesn't
|
||||
@@ -928,6 +1053,11 @@ impl Carry {
|
||||
gs: Gs::default(),
|
||||
vertical: None,
|
||||
unreadable: None,
|
||||
stroke: Vec::new(),
|
||||
line_width: None,
|
||||
ext_gstate: Vec::new(),
|
||||
dash: (Vec::new(), 0.0),
|
||||
line_cap: 0,
|
||||
};
|
||||
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT }
|
||||
}
|
||||
@@ -1041,6 +1171,14 @@ fn interpret(
|
||||
ts.fill.retain(|o| o.is("cs"));
|
||||
ts.fill.push(op.clone());
|
||||
}
|
||||
b"G" | b"RG" | b"K" | b"CS" => ts.stroke = vec![op.clone()],
|
||||
b"SC" | b"SCN" => {
|
||||
ts.stroke.retain(|o| o.is("CS"));
|
||||
ts.stroke.push(op.clone());
|
||||
}
|
||||
b"w" => ts.line_width = op.num(0).filter(|w| w.is_finite()).or(ts.line_width),
|
||||
b"d" => ts.dash = dash_pattern(doc, op.operands.first(), op.operands.get(1)).unwrap_or_else(|| ts.dash.clone()),
|
||||
b"J" => ts.line_cap = line_cap(doc, op.operands.first()).unwrap_or(ts.line_cap),
|
||||
b"Tf" => {
|
||||
ts.size = op.num(1).unwrap_or(ts.size);
|
||||
if let Some(name) = op.name(0) {
|
||||
@@ -1068,9 +1206,21 @@ fn interpret(
|
||||
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
|
||||
b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1),
|
||||
b"gs" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
select_gs(&mut ts.ext_gstate, name);
|
||||
}
|
||||
let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p));
|
||||
if let Some(params) = params.as_deref().and_then(Object::as_dict) {
|
||||
ts.gs.apply(doc, params);
|
||||
// `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5).
|
||||
let lw = params.get(b"LW").and_then(|w| doc.resolve(w).as_f64()).filter(|w| w.is_finite());
|
||||
ts.line_width = lw.or(ts.line_width);
|
||||
let dash = params.get(b"D").map(|d| doc.resolve(d));
|
||||
let dash = dash.as_deref().and_then(Object::as_array);
|
||||
if let Some(d) = dash.and_then(|d| dash_pattern(doc, d.first(), d.get(1))) {
|
||||
ts.dash = d;
|
||||
}
|
||||
ts.line_cap = line_cap(doc, params.get(b"LC")).unwrap_or(ts.line_cap);
|
||||
}
|
||||
}
|
||||
b"Do" => {
|
||||
@@ -1350,7 +1500,11 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
|
||||
(s.size - l.size).abs() < 0.01
|
||||
&& s.bold == l.bold
|
||||
&& s.italic == l.italic
|
||||
&& fill_color(&s.state.fill) == l.color
|
||||
&& same_paint(l, &s.state)
|
||||
}))
|
||||
&& near
|
||||
// An OCR layer is one text object over the whole scan: its words continue a line
|
||||
@@ -1359,13 +1513,15 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
|
||||
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ');
|
||||
if spaced {
|
||||
l.text.push(' ');
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.frame = union(l.frame, s.frame);
|
||||
l.ink.extend(s.ink.iter().copied());
|
||||
l.runs.push((s.text.clone(), l.ink.len(), spaced));
|
||||
l.ops.push(s.op);
|
||||
l.ops.extend(s.extra_ops.iter().copied());
|
||||
l.decodable &= s.decodable;
|
||||
@@ -1398,6 +1554,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
|
||||
actual_text: s.actual_text,
|
||||
clips: s.state.render_mode >= 4,
|
||||
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
|
||||
runs: vec![(s.text.clone(), s.ink.len(), false)],
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -1417,10 +1574,15 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
|
||||
// Content streams draw right-to-left lines in visual order; the editor works in typing order.
|
||||
for l in lines.iter_mut().filter(|l| !l.logical_order) {
|
||||
// A visual-order Indic font draws pre-base vowel signs before their consonant.
|
||||
let shown = l.text.clone();
|
||||
if l.visual_order && !l.actual_text {
|
||||
l.text = crate::indic::to_logical(&l.text);
|
||||
}
|
||||
l.text = crate::bidi::to_logical(&l.text);
|
||||
// The runs hold the text in drawing order, which no longer reads as the line does.
|
||||
if l.text != shown {
|
||||
l.runs.clear();
|
||||
}
|
||||
}
|
||||
// Lines of only spaces aren't editable text.
|
||||
lines.retain(|l| !l.text.trim().is_empty());
|
||||
@@ -1456,19 +1618,25 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
|
||||
(s.size - l.size).abs() < 0.01
|
||||
&& s.bold == l.bold
|
||||
&& s.italic == l.italic
|
||||
&& fill_color(&s.state.fill) == l.color
|
||||
&& same_paint(l, &s.state)
|
||||
}))
|
||||
&& pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }).0
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1);
|
||||
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ');
|
||||
if spaced {
|
||||
l.text.push(' ');
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.frame = union(l.frame, s.frame);
|
||||
l.ink.extend(s.ink.iter().copied());
|
||||
l.runs.push((s.text.clone(), l.ink.len(), spaced));
|
||||
l.ops.push(s.op);
|
||||
l.ops.extend(s.extra_ops.iter().copied());
|
||||
l.decodable &= s.decodable;
|
||||
@@ -1500,6 +1668,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
actual_text: s.actual_text,
|
||||
clips: s.state.render_mode >= 4,
|
||||
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
|
||||
runs: vec![(s.text.clone(), s.ink.len(), false)],
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -1680,7 +1849,11 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
|
||||
let mut lines = w.lines;
|
||||
// A visual-order Indic font draws pre-base vowel signs before their consonant.
|
||||
for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) {
|
||||
let shown = l.text.clone();
|
||||
l.text = crate::indic::to_logical(&l.text);
|
||||
if l.text != shown {
|
||||
l.runs.clear();
|
||||
}
|
||||
}
|
||||
lines.retain(|l| !l.text.trim().is_empty());
|
||||
Ok((lines, w.budget.truncated))
|
||||
@@ -1816,6 +1989,24 @@ fn same_font(line: &TextLine, font: &[u8], base_font: &str, face: Option<&str>)
|
||||
line.font.as_bytes() == font || same_base_font(&line.base_font, base_font) || (line.face.is_some() && line.face.as_deref() == face)
|
||||
}
|
||||
|
||||
/// Whether a run painted with `ts` continues `line` as it is painted: a line is rewritten in the
|
||||
/// state of its first run, so a `Tr`, stroke colour or line width that changes partway starts
|
||||
/// another run (see [`TextState::paint_ops`]; the `gs` parameters are compared as [`Paint`]).
|
||||
/// The stroke colour, width, dash and cap only count for text that is stroked, so a rule drawn
|
||||
/// between two words doesn't split their line. They are compared as values, wherever they were
|
||||
/// set: two ExtGStates with the same settings under other names still continue a line.
|
||||
///
|
||||
/// Only the visible modes (`Tr` 0–2) that `paint_ops` restates are compared: a line that goes on
|
||||
/// as invisible or clipping text stays one line, which the clipping refusal reads whole (#3447).
|
||||
fn same_paint(line: &TextLine, ts: &Ts) -> bool {
|
||||
let a = &line.origin.state;
|
||||
let visible = |m: i64| (0..=2).contains(&m);
|
||||
let same_mode = a.render_mode == ts.render_mode || !visible(a.render_mode) || !visible(ts.render_mode);
|
||||
let stroked = matches!((a.render_mode, ts.render_mode), (1 | 2, 1 | 2));
|
||||
let outline = a.stroke == ts.stroke && a.line_width == ts.line_width && a.dash == ts.dash && a.line_cap == ts.line_cap;
|
||||
same_mode && (!stroked || outline)
|
||||
}
|
||||
|
||||
/// Whether two `/BaseFont` names name the same font: a subset's six-letter tag and `+` prefix do
|
||||
/// not change the font (ISO 32000-2 §9.6.4), so `ABCDEF+Arial` and `GHIJKL+Arial` are one font.
|
||||
/// A name that is missing, or only a tag (`ABCDEF+`), names no font and matches nothing.
|
||||
@@ -1844,8 +2035,6 @@ fn source_family(base_font: &str) -> crate::added::Family {
|
||||
}
|
||||
}
|
||||
|
||||
const SUBSTITUTE_NAME: &str = "PCEdHelv";
|
||||
const SUBSTITUTE: &[u8] = SUBSTITUTE_NAME.as_bytes();
|
||||
const MAX_TYPE3_GLYPHS: usize = 240;
|
||||
|
||||
/// The generated fonts for one replacement: one per run of at most `max_glyphs` unique characters,
|
||||
@@ -2063,6 +2252,19 @@ fn type3_font(
|
||||
.map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?;
|
||||
glyphs.push(Type3Glyph { code, ch, path, width });
|
||||
}
|
||||
Ok(write_type3_glyphs(doc, fonts_res, face, hebrew, glyphs, per_font))
|
||||
}
|
||||
|
||||
/// Write already validated outlines; callers can prepare every face before adding any objects.
|
||||
fn write_type3_glyphs(
|
||||
doc: &mut Document,
|
||||
fonts_res: &mut Dict,
|
||||
face: &CraftFont,
|
||||
hebrew: bool,
|
||||
glyphs: Vec<Type3Glyph>,
|
||||
per_font: usize,
|
||||
) -> Type3Fallback {
|
||||
let per_font = per_font.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let mut codes = HashMap::with_capacity(glyphs.len());
|
||||
let mut widths = Vec::with_capacity(glyphs.len());
|
||||
let mut names = Vec::new();
|
||||
@@ -2078,7 +2280,7 @@ fn type3_font(
|
||||
}
|
||||
names.push(add_type3_font(doc, fonts_res, face, hebrew, group));
|
||||
}
|
||||
Ok(Type3Fallback { family: face.family, names, widths, codes })
|
||||
Type3Fallback { family: face.family, names, widths, codes }
|
||||
}
|
||||
|
||||
/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource
|
||||
@@ -2564,20 +2766,28 @@ fn paragraph_separator(c: char) -> bool {
|
||||
|
||||
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
|
||||
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
|
||||
/// those pieces join without a space.
|
||||
/// those pieces join without a space. Every white-space character is written as one space, so
|
||||
/// a run of spaces typed between, before or after words stays (#4199); only the spaces where a
|
||||
/// row wraps are dropped.
|
||||
fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for row in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
let start = out.len();
|
||||
for word in row.split(paragraph_separator).filter(|word| !word.is_empty()) {
|
||||
// The separators read since the last word: each one boundary between `split` pieces.
|
||||
let mut gap = 0usize;
|
||||
for (k, word) in row.split(paragraph_separator).enumerate() {
|
||||
if k > 0 {
|
||||
gap = gap.saturating_add(1);
|
||||
}
|
||||
if word.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let spaces = " ".repeat(std::mem::take(&mut gap));
|
||||
for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() {
|
||||
let candidate = match (line.is_empty(), i) {
|
||||
(true, _) => piece.to_string(),
|
||||
(false, 0) => format!("{line} {piece}"),
|
||||
(false, _) => format!("{line}{piece}"),
|
||||
};
|
||||
if !line.is_empty() && advance(&candidate) > width {
|
||||
let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") };
|
||||
// A row holding only the spaces that indent it doesn't wrap before its first word.
|
||||
if line.chars().any(|c| c != ' ') && advance(&candidate) > width {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = piece.to_string();
|
||||
} else {
|
||||
@@ -2585,6 +2795,7 @@ fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
line.push_str(&" ".repeat(gap));
|
||||
if !line.is_empty() || out.len() == start {
|
||||
out.push(line);
|
||||
}
|
||||
@@ -2706,7 +2917,8 @@ fn rewrite_block_from(
|
||||
&& (!style.ranges.is_empty()
|
||||
|| style.resource.is_some()
|
||||
|| (text.is_some_and(|draft| {
|
||||
draft.contains('\n')
|
||||
// A row break, `\n` or a carriage return, takes the path that keeps untouched rows.
|
||||
draft.contains(['\n', '\r'])
|
||||
|| pdfcraft_fonts::source_shaping::required(draft)
|
||||
|| !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty()
|
||||
}) || registered_face
|
||||
@@ -2716,18 +2928,24 @@ fn rewrite_block_from(
|
||||
&& style.family.is_none()
|
||||
&& style.bold.is_none())
|
||||
{
|
||||
return rewrite_styled_block(doc, page, &b, &lines, text, style);
|
||||
return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs);
|
||||
}
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
// Explicit line breaks stay (#1443); within a line, runs of white space become one space.
|
||||
let text = text
|
||||
// Explicit line breaks stay (#1443), and so does each typed U+0020 space: a run of them is
|
||||
// not folded into one (#4199, #3797). `\r\n` and a lone `\r` are breaks too; other white
|
||||
// space is written as one space.
|
||||
let text: String = text
|
||||
.unwrap_or(&b.text)
|
||||
.split('\n')
|
||||
.map(|row| row.split(paragraph_separator).filter(|word| !word.is_empty()).collect::<Vec<_>>().join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
.replace("\r\n", "\n")
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'\r' => '\n',
|
||||
c if c != '\n' && paragraph_separator(c) => ' ',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
// The characters to show, for choosing fonts: the breaks are moves, not glyphs.
|
||||
let flat = text.replace('\n', " ");
|
||||
let o = &first.origin;
|
||||
@@ -2779,8 +2997,9 @@ fn rewrite_block_from(
|
||||
// A paragraph keeps its width (or takes the one asked for); a single line grows to the right
|
||||
// (to the left when right-to-left), up to the page's margin.
|
||||
let width = match style.width {
|
||||
// Capped at the largest page PDF allows (14 400 pt).
|
||||
Some(w) if w.is_finite() => w.clamp(size * k, 14_400.0),
|
||||
// Capped at the largest page PDF allows (14 400 pt), even for text set larger than that
|
||||
// (`clamp` panics when its minimum is the larger, or NaN).
|
||||
Some(w) if w.is_finite() => w.clamp((size * k).min(14_400.0), 14_400.0),
|
||||
Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())),
|
||||
// Turned text: the page's margins are along another axis. A single line grows (below).
|
||||
None if !upright => (frame[2] - frame[0]).max(size * k),
|
||||
@@ -2881,7 +3100,9 @@ fn rewrite_block_from(
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let name = if style.family.is_none() { String::from_utf8_lossy(SUBSTITUTE).into_owned() } else { format!("PCEd{}", base.replace('-', "")) };
|
||||
// Named after its face, as the line and region rewrites name theirs: one name for every
|
||||
// stand-in would repoint the paragraphs set in an earlier one at this face (#4280).
|
||||
let name = format!("PCEd{}", base.replace('-', ""));
|
||||
fonts_res.set(name.clone().into_bytes(), Object::Dict(f));
|
||||
if style.family.is_none() {
|
||||
substituted = Some(base.to_string());
|
||||
@@ -2906,6 +3127,21 @@ fn rewrite_block_from(
|
||||
None => size * 1.2,
|
||||
};
|
||||
let n = pdfcraft_content::num;
|
||||
// The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold
|
||||
// second copy, an invisible text layer): the new text is all that may show.
|
||||
let drop = dropped;
|
||||
// Text shown earlier in the same text object stays first: the paragraph then goes where its
|
||||
// last row was, after any backgrounds between the rows, or between two halves of the text object.
|
||||
let start = last.ops.first().copied().unwrap_or(emit.bt_op);
|
||||
let split = ops
|
||||
.get(emit.bt_op..start)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i)));
|
||||
// What the text after the paragraph expects, and what is in force where the paragraph is
|
||||
// drawn: the state at its BT (or where it was split).
|
||||
let after = if split { &emit.state } else { &emit.bt_state };
|
||||
// In its own graphics state, so a new colour (or anything else) stops at the paragraph.
|
||||
let mut block_ops = vec![Op::new("q", vec![])];
|
||||
block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?);
|
||||
@@ -2913,6 +3149,8 @@ fn rewrite_block_from(
|
||||
if let Some((ax, ay)) = movement {
|
||||
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)]));
|
||||
}
|
||||
// Painted as the paragraph was, including what its text object set before the first show.
|
||||
block_ops.extend(o_state.paint_ops(after));
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
let mut state = o_state.clone();
|
||||
state.word_spacing = 0.0;
|
||||
@@ -2925,12 +3163,13 @@ fn rewrite_block_from(
|
||||
block_ops.extend(state.ops());
|
||||
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
|
||||
// Alignment: each line's offset from the left edge, in text space.
|
||||
let offset = |line: &str| -> f64 {
|
||||
let free = (width - advance(line)).max(0.0) / k;
|
||||
// `visible` is the width the line is aligned on (see the loop below).
|
||||
let offset = |visible: f64| -> f64 {
|
||||
let free = (width - visible).max(0.0) / k;
|
||||
match align {
|
||||
Some(crate::added::Align::Center) => free / 2.0,
|
||||
// A right-to-left line anchored at its right edge may start left of the old box.
|
||||
Some(crate::added::Align::Right) if anchored_right => (right_edge - advance(line)) / k,
|
||||
Some(crate::added::Align::Right) if anchored_right => (right_edge - visible) / k,
|
||||
Some(crate::added::Align::Right) => free,
|
||||
// A justified right-to-left paragraph ends its last line at the right edge.
|
||||
Some(crate::added::Align::Justify) if rtl => free,
|
||||
@@ -2950,11 +3189,29 @@ fn rewrite_block_from(
|
||||
let mut tw_set = 0.0;
|
||||
let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space
|
||||
for (i, line) in wrapped.iter().enumerate() {
|
||||
let spaces = line.matches(' ').count();
|
||||
let tw =
|
||||
if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 };
|
||||
// A stretched line fills the width from the left edge.
|
||||
let dx = if tw > 0.0 { 0.0 } else { offset(line) };
|
||||
// A stretched (justified) line shows only its words, as before #4199: word spacing would
|
||||
// reach the spaces before its first word and after its last, so they are left out, and
|
||||
// the line fills the width from the left edge. Nothing to stretch over, or a paragraph
|
||||
// drawn at `0 Tz`, isn't justified.
|
||||
let body = line.trim_matches(' ');
|
||||
let interior = body.matches(' ').count();
|
||||
let tw = if justify && i + 1 < wrapped.len() && interior > 0 && o_state.scale.abs() > 1e-6 {
|
||||
Some((width - advance(body)).max(0.0) / k / o_state.scale / interior as f64).filter(|tw| tw.is_finite())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.unwrap_or(0.0);
|
||||
// Other lines show every space typed. A left-to-right one is aligned on its width up to
|
||||
// its last word; a right-to-left one on its whole width, since the spaces typed after its
|
||||
// last word are drawn on its left and the ones before its first on its right.
|
||||
let (shown, visible) = if tw > 0.0 {
|
||||
(body, advance(body))
|
||||
} else if crate::bidi::rtl_base(line) {
|
||||
(line.as_str(), advance(line))
|
||||
} else {
|
||||
(line.as_str(), advance(line.trim_end_matches(' ')))
|
||||
};
|
||||
let dx = if tw > 0.0 { 0.0 } else { offset(visible) };
|
||||
if i > 0 || dx != 0.0 {
|
||||
block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })]));
|
||||
}
|
||||
@@ -2966,7 +3223,7 @@ fn rewrite_block_from(
|
||||
tw_set = tw;
|
||||
}
|
||||
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
|
||||
let visual = crate::bidi::to_visual(line);
|
||||
let visual = crate::bidi::to_visual(shown);
|
||||
// Stretched without word spacing: shown up to and including each space, then moved on.
|
||||
let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
|
||||
for piece in pieces {
|
||||
@@ -2987,7 +3244,8 @@ fn rewrite_block_from(
|
||||
}
|
||||
}
|
||||
let tw = tw + stretch;
|
||||
let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k;
|
||||
// Underlined to the line's last word, over its stretched gaps.
|
||||
let w = (visible + tw * interior as f64 * o_state.scale * k) / k;
|
||||
underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12));
|
||||
}
|
||||
block_ops.push(Op::new("ET", vec![]));
|
||||
@@ -3017,21 +3275,6 @@ fn rewrite_block_from(
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
}
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
// The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold
|
||||
// second copy, an invisible text layer): the new text is all that may show.
|
||||
let mut drop = coincident_ops(&lines, &members);
|
||||
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
|
||||
// Text shown earlier in the same text object stays first: the paragraph then goes where its
|
||||
// last row was, after any backgrounds between the rows, or between two halves of the text object.
|
||||
let start = last.ops.first().copied().unwrap_or(emit.bt_op);
|
||||
let split = ops
|
||||
.get(emit.bt_op..start)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i)));
|
||||
// What the text after the paragraph expects: the state at its BT (or where it was split).
|
||||
let after = if split { &emit.state } else { &emit.bt_state };
|
||||
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
|
||||
if split {
|
||||
block_ops.insert(0, Op::new("ET", vec![]));
|
||||
@@ -3353,7 +3596,23 @@ mod tests {
|
||||
assert_eq!(wrap("A B C", 4.0, width), ["A B", "C"]);
|
||||
assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]);
|
||||
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
|
||||
assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]);
|
||||
}
|
||||
|
||||
/// #4199: a run of spaces stays a run, before, between and after words; the spaces where a
|
||||
/// row wraps go, and each other white-space character is one space.
|
||||
#[test]
|
||||
fn paragraph_wrapping_keeps_every_space_typed() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("a b c", 20.0, width), ["a b c"]);
|
||||
assert_eq!(wrap(" a b ", 20.0, width), [" a b "]);
|
||||
assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]);
|
||||
assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]);
|
||||
// An indent alone on a row doesn't wrap before its first word.
|
||||
assert_eq!(wrap(" abc", 3.0, width), [" abc"]);
|
||||
// The rewrite turns `\r\n` and `\r` into `\n` before wrapping.
|
||||
assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]);
|
||||
assert_eq!(wrap(" ", 3.0, width), [" "]);
|
||||
assert_eq!(wrap("", 3.0, width), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+104
-32
@@ -2,6 +2,18 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces.
|
||||
// A glyph budget worth 64 full batches (15,360 glyphs with the production batch size).
|
||||
// Lowering the internal batch size for tests lowers the budget by the same ratio.
|
||||
const MAX_STYLED_FALLBACK_BATCHES: usize = 64;
|
||||
|
||||
struct FallbackGroup {
|
||||
face: &'static CraftFont,
|
||||
seen: std::collections::HashSet<char>,
|
||||
glyphs: Vec<Type3Glyph>,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
|
||||
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
|
||||
let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm));
|
||||
@@ -166,8 +178,8 @@ fn opens_with_marker(text: &str) -> bool {
|
||||
if !first.is_ascii_digit() && !first.is_ascii_alphabetic() {
|
||||
return false;
|
||||
}
|
||||
let digits = text.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
let letters = text.chars().take_while(|c| c.is_ascii_alphabetic()).count();
|
||||
let digits = text.chars().take_while(char::is_ascii_digit).count();
|
||||
let letters = text.chars().take_while(char::is_ascii_alphabetic).count();
|
||||
// Both runs are ASCII characters, so their length in characters is their length in bytes:
|
||||
// the slice below starts on a character boundary.
|
||||
let rest = if digits > 0 {
|
||||
@@ -178,7 +190,7 @@ fn opens_with_marker(text: &str) -> bool {
|
||||
return false;
|
||||
};
|
||||
let mut after = rest.chars();
|
||||
matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(|c| c.is_whitespace())
|
||||
matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// A row that opens with a list marker opens its own item: joining it to the row above is what
|
||||
@@ -400,6 +412,7 @@ pub(super) fn rewrite_styled_block(
|
||||
lines: &[TextLine],
|
||||
text: Option<&str>,
|
||||
style: &BlockStyle,
|
||||
max_glyphs: usize,
|
||||
) -> Result<LineEdit, EditError> {
|
||||
let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
|
||||
let draft = match text {
|
||||
@@ -542,16 +555,29 @@ pub(super) fn rewrite_styled_block(
|
||||
// Each tuple is a character, its source style and an encodable resource name.
|
||||
let mut chars = Vec::new();
|
||||
let mut substituted = None;
|
||||
let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new();
|
||||
let mut fallback_groups: Vec<FallbackGroup> = Vec::new();
|
||||
let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new();
|
||||
let mut fallback_glyphs = 0_usize;
|
||||
let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES);
|
||||
let mut full_faces: HashMap<String, String> = HashMap::new();
|
||||
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = HashMap::new();
|
||||
for (range, index) in styled_spans {
|
||||
let source = lines.get(index).unwrap_or(first);
|
||||
for ch in draft.get(range).unwrap_or_default().chars() {
|
||||
let start = range.start;
|
||||
for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() {
|
||||
if ch == '\n' {
|
||||
chars.push((ch, index, source.font.clone()));
|
||||
continue;
|
||||
}
|
||||
// A carriage return breaks the row too. Before `\n` the pair is one break, so the
|
||||
// CR stays a `\r` the rows skip. Either way it keeps its place among the draft's
|
||||
// characters, which rows are counted in.
|
||||
if ch == '\r' {
|
||||
let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n'));
|
||||
chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone()));
|
||||
continue;
|
||||
}
|
||||
let s = ch.to_string();
|
||||
let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) {
|
||||
if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) {
|
||||
@@ -605,24 +631,40 @@ pub(super) fn rewrite_styled_block(
|
||||
} else {
|
||||
let family = source_family(&source.base_font);
|
||||
if needs_type3(&s) {
|
||||
let key = (source.base_font.rsplit('+').next().unwrap_or(&source.base_font).to_owned(), ch);
|
||||
if let Some(name) = fallback_names.get(&key) {
|
||||
chars.push((ch, index, name.clone()));
|
||||
continue;
|
||||
let key = (source.base_font.clone(), source.bold, ch);
|
||||
let group_index = if let Some(index) = fallback_names.get(&key) {
|
||||
*index
|
||||
} else {
|
||||
// Keep the old per-character face choice: a serif Japanese character
|
||||
// and a Cyrillic character may need different faces even in one style.
|
||||
// Hebrew uses the separate RTL path before reaching this writer.
|
||||
let (faces, _) = type3_faces(&s, family, source.bold)?;
|
||||
let face =
|
||||
fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?;
|
||||
let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| {
|
||||
let index = fallback_groups.len();
|
||||
fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() });
|
||||
index
|
||||
});
|
||||
fallback_names.insert(key, index);
|
||||
index
|
||||
};
|
||||
let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?;
|
||||
if group.seen.insert(ch) {
|
||||
if fallback_glyphs >= fallback_budget {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font"
|
||||
)));
|
||||
}
|
||||
let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?;
|
||||
let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1))
|
||||
.map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?;
|
||||
group.glyphs.push(Type3Glyph { code, ch, path, width });
|
||||
fallback_glyphs = fallback_glyphs.saturating_add(1);
|
||||
}
|
||||
if fallback_names.len() >= MAX_TYPE3_GLYPHS {
|
||||
return Err(EditError::Invalid("the replacement has too many fallback glyphs".into()));
|
||||
}
|
||||
let fallback = type3_font(doc, &mut fonts, &s, family, source.bold, MAX_TYPE3_GLYPHS)?;
|
||||
let name = fallback.names.first().cloned().ok_or_else(no_japanese_font)?;
|
||||
let dict = fonts
|
||||
.get(name.as_bytes())
|
||||
.and_then(|f| doc.resolve(f).as_dict().cloned())
|
||||
.ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?;
|
||||
metrics.insert(name.clone(), Metrics::from_dict(doc, &dict));
|
||||
substituted = Some(fallback.family.to_string());
|
||||
fallback_names.insert(key, name.clone());
|
||||
name
|
||||
group.positions.push(chars.len());
|
||||
// Filled from the prepared font/code map before measuring or drawing.
|
||||
String::new()
|
||||
} else {
|
||||
let base = family.base_font(source.bold, source.italic);
|
||||
let name = format!("PCEd{}", base.replace('-', ""));
|
||||
@@ -640,6 +682,24 @@ pub(super) fn rewrite_styled_block(
|
||||
chars.push((ch, index, name));
|
||||
}
|
||||
}
|
||||
// All fallback outlines have now succeeded. Batch by actual face, preserving source
|
||||
// style indices; the existing writer then coalesces adjacent equal-font/style characters.
|
||||
for group in fallback_groups {
|
||||
let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font);
|
||||
for name in &fallback.names {
|
||||
let font = fonts
|
||||
.get(name.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?;
|
||||
metrics.insert(name.clone(), Metrics::from_dict(doc, &font));
|
||||
}
|
||||
for position in group.positions {
|
||||
let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?;
|
||||
let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?;
|
||||
item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?;
|
||||
}
|
||||
substituted = Some(fallback.family.to_owned());
|
||||
}
|
||||
let size_of = |source: &TextLine, index: usize| {
|
||||
overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
|
||||
};
|
||||
@@ -735,6 +795,11 @@ pub(super) fn rewrite_styled_block(
|
||||
let mut at = 0;
|
||||
while at < chars.len() {
|
||||
let Some(item) = chars.get(at) else { break };
|
||||
// The CR of a CRLF: the `\n` after it breaks the row.
|
||||
if item.0 == '\r' {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
if item.0 == '\n' {
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
@@ -800,6 +865,17 @@ pub(super) fn rewrite_styled_block(
|
||||
}
|
||||
};
|
||||
let o = &lead.origin;
|
||||
let mut drop = coincident_ops(lines, &members);
|
||||
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
|
||||
let start = lead.ops.first().copied().unwrap_or(o.bt_op);
|
||||
let split = ops
|
||||
.get(o.bt_op..start)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i)));
|
||||
// What is in force where the rewrite is drawn, and what the text after it expects.
|
||||
let after = if split { &o.state } else { &o.bt_state };
|
||||
for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) {
|
||||
let text: String = row.iter().map(|v| v.0).collect();
|
||||
if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) {
|
||||
@@ -910,6 +986,11 @@ pub(super) fn rewrite_styled_block(
|
||||
tm[4] += ax;
|
||||
tm[5] += ay;
|
||||
let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0);
|
||||
// Each run in its own graphics state, restating what its source set inside its text
|
||||
// object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is
|
||||
// drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next.
|
||||
block_ops.push(Op::new("q", vec![]));
|
||||
block_ops.extend(state.paint_ops(after));
|
||||
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
|
||||
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let mut properties = Dict::new();
|
||||
@@ -996,6 +1077,7 @@ pub(super) fn rewrite_styled_block(
|
||||
block_ops.push(Op::new("l", vec![n(x1), n(y1)]));
|
||||
block_ops.push(Op::new("S", vec![]));
|
||||
}
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
x += segment_width;
|
||||
pos = end;
|
||||
}
|
||||
@@ -1006,16 +1088,6 @@ pub(super) fn rewrite_styled_block(
|
||||
};
|
||||
}
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
let mut drop = coincident_ops(lines, &members);
|
||||
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
|
||||
let start = lead.ops.first().copied().unwrap_or(o.bt_op);
|
||||
let split = ops
|
||||
.get(o.bt_op..start)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i)));
|
||||
let after = if split { &o.state } else { &o.bt_state };
|
||||
block_ops.extend(after.ops());
|
||||
if split {
|
||||
block_ops.insert(0, Op::new("ET", vec![]));
|
||||
|
||||
@@ -16,6 +16,14 @@ Two frontends exist on top of it today: `pdfcraft-ui-egui` (desktop and web) and
|
||||
`pdfcraft-automation` (headless tools, the CLI and MCP). Anything one can do, the other must be
|
||||
able to do — see AGENTS.md §3.
|
||||
|
||||
When dynamic XFA layout is active, `JsRunner` does not initialize Acrobat JavaScript with
|
||||
scripts from the PDF container's `/Names /JavaScript` tree. Those scripts belong to the
|
||||
fallback PDF view and can otherwise show a misleading viewer-compatibility warning.
|
||||
They remain in the document and in `Document::document_scripts`. Requested console scripts,
|
||||
field actions and XFA events can still show messages; ordinary PDFs, static XFA and shells
|
||||
without an active layout keep their document scripts. Converting XFA to a static form and
|
||||
undoing that conversion switches the context with the document state.
|
||||
|
||||
## The editing model
|
||||
|
||||
Each open document holds a `pdfcraft_cos::Document`: the object graph plus a copy-on-write overlay.
|
||||
|
||||
@@ -147,7 +147,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
item("Link", "link-2", "edit.link", Ready),
|
||||
item("Bates numbering", "hash", "edit.bates", Ready),
|
||||
menu_item("Background", "palette", &["edit.background", "edit.background.update", "edit.background.remove"]),
|
||||
item("Attach file", "paperclip", "edit.attach", Planned("M12")),
|
||||
item("Attach file", "paperclip", "edit.attach", Ready),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -596,6 +596,19 @@ mod tests {
|
||||
assert!(menus >= 3);
|
||||
}
|
||||
|
||||
// Regression test for #2028: Edit text & images sat under "Add content", though it changes
|
||||
// what is already on the page; it belongs first under "Modify page".
|
||||
#[test]
|
||||
fn edit_text_and_images_is_the_first_item_under_modify_page() {
|
||||
let edit = group("edit").expect("the Edit a PDF tool");
|
||||
let section_of = |command: &str| edit.sections.iter().find(|s| s.items.iter().any(|i| i.command == command)).map(|s| s.title);
|
||||
assert_eq!(section_of("edit.edit_text"), Some("Modify page"), "Edit text & images is a Modify page tool");
|
||||
let modify = edit.sections.iter().find(|s| s.title == "Modify page").expect("a Modify page section");
|
||||
assert_eq!(modify.items.first().map(|i| i.command), Some("edit.edit_text"), "and the first one, so it is not below the fold");
|
||||
let add = edit.sections.iter().find(|s| s.title == "Add content").expect("an Add content section");
|
||||
assert!(add.items.iter().all(|i| i.command != "edit.edit_text"), "it adds nothing, so it is not listed under Add content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_update_and_remove_command_is_reachable_from_a_tool_entry() {
|
||||
let reachable: HashSet<&str> =
|
||||
|
||||
@@ -282,6 +282,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("edit.text", "Add text", None, None, Modification, "type"),
|
||||
c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"),
|
||||
c("edit.link", "Add or edit links", None, None, Modification, "link-2"),
|
||||
c("edit.attach", "Attach a file…", EDIT, None, Modification, "paperclip"),
|
||||
c("edit.links_from_urls", "Create links from URLs", None, None, Modification, "link-2"),
|
||||
c("edit.remove_links", "Remove all links", None, None, Modification, "trash-2"),
|
||||
c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Compare files: the text differences between two open documents, a report, and the
|
||||
//! differences marked as comments in the newer one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::ObjRef;
|
||||
|
||||
pub use pdfcraft_compare::{Change, Comparison, Kind, Side};
|
||||
// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages
|
||||
// that draw more.
|
||||
@@ -10,6 +13,10 @@ pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
|
||||
|
||||
/// Most bytes of encoded pictures one Office export prepares, counting each image object once
|
||||
/// (the Word and PowerPoint writers keep at most 512 MiB of them as well).
|
||||
pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Acrobat's compare colours: replaced blue, inserted green, deleted red.
|
||||
pub fn colour(kind: Kind) -> crate::Rgb {
|
||||
match kind {
|
||||
@@ -163,23 +170,32 @@ pub struct OfficeExport {
|
||||
/// The exported file.
|
||||
pub bytes: Vec<u8>,
|
||||
/// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
|
||||
/// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so
|
||||
/// the text and images of those forms are missing from `bytes`.
|
||||
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops
|
||||
/// reading there to stay bounded on hostile files, so the text and images of those forms, or
|
||||
/// those pictures, are missing from `bytes`.
|
||||
pub truncated_pages: Vec<usize>,
|
||||
}
|
||||
|
||||
/// An image object prepared for Office export: its file extension and encoded bytes, or why it is
|
||||
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded).
|
||||
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
|
||||
|
||||
impl crate::Document {
|
||||
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
|
||||
/// form XObjects draw.
|
||||
pub fn export_pages(&self) -> Vec<pdfcraft_export::Page> {
|
||||
self.export_pages_checked().0
|
||||
self.export_pages_checked(true, MAX_EXPORT_IMAGE_BYTES).0
|
||||
}
|
||||
|
||||
/// [`Self::export_pages`], and the pages whose forms were left partly unread
|
||||
/// ([`OfficeExport::truncated_pages`]).
|
||||
fn export_pages_checked(&self) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
|
||||
/// [`Self::export_pages`], and the pages whose forms were left partly unread or whose
|
||||
/// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without
|
||||
/// `images` no picture is prepared.
|
||||
pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
|
||||
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
|
||||
let mut truncated = Vec::new();
|
||||
// Each image object is encoded once and shared by every place that draws it.
|
||||
let mut encoded: HashMap<ObjRef, PreparedImage> = HashMap::new();
|
||||
let mut image_bytes = image_budget;
|
||||
let pages = self
|
||||
.info
|
||||
.pages
|
||||
@@ -197,9 +213,7 @@ impl crate::Document {
|
||||
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
|
||||
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
|
||||
let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
|
||||
if reading.truncated {
|
||||
truncated.push(i);
|
||||
}
|
||||
let forms_unread = reading.truncated;
|
||||
let blocks = reading
|
||||
.blocks
|
||||
.into_iter()
|
||||
@@ -217,13 +231,21 @@ impl crate::Document {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let images = pdfcraft_edit::reading_images(cos, i)
|
||||
.unwrap_or_default()
|
||||
let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() };
|
||||
let mut over_budget = false;
|
||||
let images = placed
|
||||
.iter()
|
||||
.filter_map(|im| {
|
||||
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
|
||||
let object = im.object?;
|
||||
let prepared = encoded.entry(object).or_insert_with(|| {
|
||||
let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?;
|
||||
image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?;
|
||||
Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes)))
|
||||
});
|
||||
over_budget |= matches!(prepared, Err(true));
|
||||
let (ext, bytes) = prepared.clone().ok()?;
|
||||
Some(pdfcraft_export::Image {
|
||||
ext: if ext == "jpg" { "jpg" } else { "png" },
|
||||
ext,
|
||||
bytes,
|
||||
rect: visible_rect(im.rect),
|
||||
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
|
||||
@@ -231,6 +253,9 @@ impl crate::Document {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if forms_unread || over_budget {
|
||||
truncated.push(i);
|
||||
}
|
||||
// The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one.
|
||||
let background = pdfcraft_edit::page_background(cos, i).ok().flatten();
|
||||
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background }
|
||||
@@ -247,7 +272,8 @@ impl crate::Document {
|
||||
/// [`Self::export_office`], and the pages whose text it had to leave out
|
||||
/// ([`OfficeExport::truncated_pages`]).
|
||||
pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
|
||||
let (pages, truncated_pages) = self.export_pages_checked();
|
||||
// RTF leaves pictures out.
|
||||
let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES);
|
||||
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
|
||||
let bytes = match format {
|
||||
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
|
||||
|
||||
@@ -59,6 +59,57 @@ impl Document {
|
||||
let rgba = image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?.into_rgba8();
|
||||
Ok((rgba.width(), rgba.height(), rgba.into_raw()))
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy of an added image (`index` among `page`'s added items): its picture as a file
|
||||
/// (extension, bytes), a JPEG or JPEG 2000 file as stored, otherwise a lossless PNG, for
|
||||
/// [`Edit::PasteContent`](crate::Edit::PasteContent). Refused when the document doesn't allow
|
||||
/// copying.
|
||||
pub fn added_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
|
||||
self.check_copying().map_err(|e| e.to_string())?;
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let item = self.added().iter().filter(|a| a.page == page).nth(index);
|
||||
let Some(pdfcraft_edit::Content::Image(img)) = item.map(|a| &a.content) else {
|
||||
return Err(format!("item {} on page {} is not an added image", index + 1, page + 1));
|
||||
};
|
||||
picture_file(&editor.cos, img.image)
|
||||
}
|
||||
}
|
||||
|
||||
/// An added image's picture as a file: [`pdfcraft_create::image_file`], except that a JPEG 2000
|
||||
/// picture, which Add Image stores as the file it was given, is that file.
|
||||
fn picture_file(cos: &Cos, image: ObjRef) -> Result<(&'static str, Vec<u8>), String> {
|
||||
match &*cos.get(image) {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") && s.dict.name(b"Filter") == Some(b"JPXDecode") => Ok(("jp2", s.raw.to_vec())),
|
||||
_ => pdfcraft_create::image_file(cos, image),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether image object `image` in `cos` is the picture `file` (as [`picture_file`] writes it).
|
||||
/// The sizes are compared first, so an unrelated image is not re-encoded to find out.
|
||||
pub(crate) fn is_picture(cos: &Cos, image: ObjRef, file: &[u8]) -> bool {
|
||||
let size = |s: &pdfcraft_cos::Stream| Some((u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?));
|
||||
// The object at that number now, not one of an earlier generation of it.
|
||||
if cos.generation(image.num) != image.generation {
|
||||
return false;
|
||||
}
|
||||
let stored = match &*cos.get(image) {
|
||||
// A JPEG 2000 picture is stored as its file.
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") && s.dict.name(b"Filter") == Some(b"JPXDecode") => return s.raw[..] == *file,
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => size(s),
|
||||
_ => return false,
|
||||
};
|
||||
let given = image::ImageReader::new(std::io::Cursor::new(file)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok());
|
||||
stored.is_some() && stored == given && picture_file(cos, image).is_ok_and(|(_, data)| data == file)
|
||||
}
|
||||
|
||||
/// The image object an added image in `cos` already draws picture `file` from, if any: where a
|
||||
/// second paste from another document finds the first one's.
|
||||
pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option<ObjRef> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
pdfcraft_edit::list_added(cos).into_iter().find_map(|a| match a.content {
|
||||
pdfcraft_edit::Content::Image(img) if seen.insert(img.image) && is_picture(cos, img.image, file) => Some(img.image),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The largest raster image Form `form` draws through its resources (nested Forms included).
|
||||
|
||||
+120
-16
@@ -7,7 +7,7 @@
|
||||
//! off, scripts don't run and AF calls keep working natively.
|
||||
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
|
||||
pub use pdfcraft_js::{Limits, Outcome, Request};
|
||||
pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
|
||||
@@ -19,25 +19,63 @@ pub struct JsOutput {
|
||||
pub requests: Vec<Request>,
|
||||
/// Script errors (as Acrobat's console shows them).
|
||||
pub errors: Vec<String>,
|
||||
/// Which of `errors` are document scripts' (#3940), told apart by where they came from and
|
||||
/// not by their text, which a field script's error can share: each is reported once, however
|
||||
/// often the document scripts run again.
|
||||
pub doc_errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl JsOutput {
|
||||
/// Whether scripts produced nothing: no alerts, console lines, requests or errors.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
|
||||
}
|
||||
|
||||
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
||||
/// except a request to show a side panel or open Save As, which is noted on the console
|
||||
/// and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
self.alerts.extend(o.alerts.iter().cloned());
|
||||
self.console.extend(o.console.iter().cloned());
|
||||
self.requests.extend(o.requests.iter().cloned());
|
||||
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
|
||||
// button's may show or hide a side panel, or open Save As.
|
||||
for r in &o.requests {
|
||||
match r {
|
||||
Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
|
||||
Request::SaveAs => {
|
||||
self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into())
|
||||
}
|
||||
r => self.requests.push(r.clone()),
|
||||
}
|
||||
}
|
||||
self.errors.extend(o.error.iter().cloned());
|
||||
// The document scripts run before every field script, so a failing one would repeat
|
||||
// once per calculation; it is reported once.
|
||||
for e in &o.doc_errors {
|
||||
if !self.doc_errors.contains(e) {
|
||||
self.doc_errors.push(e.clone());
|
||||
self.errors.push(e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn append(&mut self, other: JsOutput) {
|
||||
self.alerts.extend(other.alerts);
|
||||
self.console.extend(other.console);
|
||||
self.requests.extend(other.requests);
|
||||
self.errors.extend(other.errors);
|
||||
// As in `absorb`: a document script's error, already reported, is not repeated; a field
|
||||
// script's errors are each their own, even one worded like a document script's.
|
||||
let mut docs = other.doc_errors;
|
||||
for e in other.errors {
|
||||
if let Some(k) = docs.iter().position(|d| *d == e) {
|
||||
docs.swap_remove(k);
|
||||
if self.doc_errors.contains(&e) {
|
||||
continue;
|
||||
}
|
||||
self.doc_errors.push(e.clone());
|
||||
}
|
||||
self.errors.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,15 +167,19 @@ pub struct JsRunner {
|
||||
}
|
||||
|
||||
impl JsRunner {
|
||||
/// A runner for `cos`: what scripts read about the document (pages, info, whether it is
|
||||
/// an XFA form) and its document-level scripts, read once.
|
||||
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner {
|
||||
let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) };
|
||||
JsRunner {
|
||||
doc,
|
||||
doc_scripts: pdfcraft_forms::document_scripts(cos),
|
||||
output: JsOutput::default(),
|
||||
cos: cos.clone(),
|
||||
displays: std::collections::HashMap::new(),
|
||||
}
|
||||
let xfa = pdfcraft_xfa::xfa_shape(cos).is_some();
|
||||
let doc =
|
||||
pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos), xfa };
|
||||
// Once XFA owns the form, the PDF container's scripts belong to its fallback view.
|
||||
// Keep them in the file and in Document JavaScripts, but do not initialize an
|
||||
// AcroForm context with them: a viewer-compatibility alert would be misleading.
|
||||
// Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts.
|
||||
let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some();
|
||||
let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) };
|
||||
JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +234,11 @@ impl crate::Document {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Field Properties ▸ Actions: how many actions are chained after each trigger's first one.
|
||||
pub fn field_action_chains(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, usize)> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_action_chains(&e.cos, name).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Document JavaScripts (name, source), in name order.
|
||||
pub fn document_scripts(&self) -> Vec<(String, String)> {
|
||||
self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default()
|
||||
@@ -289,6 +336,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Characters read to tell underscore runs from other words.
|
||||
static UNDERSCORE_CHARS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered
|
||||
/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them).
|
||||
pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> {
|
||||
@@ -296,6 +349,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
|
||||
let mut last_line = u32::MAX;
|
||||
// A space glyph ends the word before it.
|
||||
let mut gap = false;
|
||||
// Whether the last word is an underscore run (a form's blank), which other glyphs don't join.
|
||||
let mut last_under = false;
|
||||
for (i, g) in text.glyphs.iter().enumerate() {
|
||||
let line = text.line_of.get(i).copied().unwrap_or(0);
|
||||
if g.text.trim().is_empty() {
|
||||
@@ -303,10 +358,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
|
||||
continue;
|
||||
}
|
||||
let under = g.text.chars().all(|c| c == '_');
|
||||
let breaks = match words.last() {
|
||||
None => true,
|
||||
Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under,
|
||||
};
|
||||
#[cfg(test)]
|
||||
UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len()));
|
||||
let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under;
|
||||
gap = false;
|
||||
last_line = line;
|
||||
match words.last_mut() {
|
||||
@@ -317,7 +371,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
|
||||
r[2] = r[2].max(g.rect[2]);
|
||||
r[3] = r[3].max(g.rect[3]);
|
||||
}
|
||||
_ => words.push((g.text.clone(), g.rect)),
|
||||
_ => {
|
||||
words.push((g.text.clone(), g.rect));
|
||||
last_under = under;
|
||||
}
|
||||
}
|
||||
}
|
||||
words
|
||||
@@ -409,6 +466,53 @@ impl Session {
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
|
||||
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
|
||||
/// the word so far without reading the word again.
|
||||
#[test]
|
||||
fn a_long_underscore_run_is_read_in_one_pass() {
|
||||
let n = 10_000;
|
||||
let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph {
|
||||
text: t.into(),
|
||||
rect: [i as f32, 0.0, i as f32 + 1.0, 10.0],
|
||||
quad: [[0.0; 2]; 4],
|
||||
direction: [1.0, 0.0],
|
||||
};
|
||||
let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect();
|
||||
glyphs.push(glyph(n, "x"));
|
||||
let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs };
|
||||
UNDERSCORE_CHARS_READ.with(|c| c.set(0));
|
||||
let words = words_with(&text, |x, y| [x, y]);
|
||||
assert_eq!(words.iter().map(|(w, _)| w.len()).collect::<Vec<_>>(), [n, 1]);
|
||||
let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get);
|
||||
assert!(read <= 4 * n, "{read} characters read for {n} glyphs");
|
||||
}
|
||||
|
||||
/// Review on #3985: merging two outputs reports a failing document script once, as one run
|
||||
/// does, while each field script error stays: told apart by where an error came from, so a
|
||||
/// field script that throws a document script's very words is still reported.
|
||||
#[test]
|
||||
fn merged_output_reports_a_document_script_error_once() {
|
||||
let doc = "document script 2: TypeError: not a callable function";
|
||||
let field = "ReferenceError: x is not defined";
|
||||
let run = |error: &str| {
|
||||
let mut out = JsOutput::default();
|
||||
out.absorb(&Outcome { error: Some(error.into()), doc_errors: vec![doc.into()], ..Outcome::default() });
|
||||
out
|
||||
};
|
||||
let mut out = run(field);
|
||||
out.append(run(field));
|
||||
assert_eq!(out.errors, [field, doc, field]);
|
||||
// CodeRabbit on #3985: a field script that throws the document script's text.
|
||||
out.append(run(doc));
|
||||
assert_eq!(out.errors, [field, doc, field, doc]);
|
||||
let alone = run(doc);
|
||||
assert_eq!(alone.errors, [doc, doc], "the field's error and the document script's");
|
||||
let count = |o: &JsOutput| o.errors.iter().filter(|e| *e == doc).count();
|
||||
let mut merged = run(field);
|
||||
merged.append(alone);
|
||||
assert_eq!(count(&merged), 2, "the field's copy stays; the document script's is listed once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_field_names_diff_against_first_original() {
|
||||
let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]);
|
||||
|
||||
+272
-35
@@ -46,7 +46,7 @@ pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::StampPreview;
|
||||
pub use text_preview::TextEditPreview;
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges};
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
|
||||
|
||||
/// One file produced by a split: (1-based first page, last page, PDF bytes).
|
||||
pub use pdfcraft_organize::LabelStyle;
|
||||
@@ -55,7 +55,10 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific
|
||||
|
||||
pub use pdfcraft_cos::Algorithm;
|
||||
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
|
||||
pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width};
|
||||
pub use pdfcraft_edit::added::{
|
||||
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
|
||||
text_width as added_text_width,
|
||||
};
|
||||
pub use pdfcraft_edit::{
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
};
|
||||
@@ -276,6 +279,8 @@ fn uses_scripts(edit: &Edit) -> bool {
|
||||
fn scope_of(edit: &Edit) -> Scope {
|
||||
match edit {
|
||||
Edit::SetInfo { .. } => Scope::Metadata,
|
||||
// The Attachments list is read from the whole document.
|
||||
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full,
|
||||
// A file attachment also changes the Attachments list.
|
||||
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
|
||||
Edit::AddMeasurement(_)
|
||||
@@ -309,6 +314,7 @@ fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::UpdateContent { .. }
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::AddHeaderFooter { .. }
|
||||
| Edit::AddWatermark { .. }
|
||||
| Edit::AddBackground { .. }
|
||||
@@ -787,6 +793,17 @@ impl Document {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.modify())
|
||||
}
|
||||
|
||||
/// How widget `widget` of text field `name` lays its value out, for asking whether a typed
|
||||
/// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no
|
||||
/// more than fits. `None` without such a field or widget, or when the document can't be
|
||||
/// edited.
|
||||
pub fn field_text_box(&self, name: &str, widget: usize) -> Option<form_appearance::TextBox> {
|
||||
let cos = &self.editor.as_ref()?.cos;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
let w = f.widgets.get(widget)?;
|
||||
Some(form_appearance::text_box(cos, f, w))
|
||||
}
|
||||
|
||||
/// Filling in form fields is allowed (Table 22, bits 6 and 9).
|
||||
pub fn allows_form_filling(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.fill_forms())
|
||||
@@ -847,6 +864,14 @@ impl Document {
|
||||
matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f))
|
||||
}
|
||||
|
||||
/// Whether text field `name`'s value is cut off by its box at the field's font size (#4018):
|
||||
/// the page (0-based) of the first widget that cuts it, and how.
|
||||
pub fn field_overflow(&self, name: &str) -> Option<(Option<usize>, pdfcraft_forms::appearance::Overflow)> {
|
||||
let e = self.editor.as_ref()?;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o)))
|
||||
}
|
||||
|
||||
/// Remove Hidden Information: what each category would remove.
|
||||
pub fn hidden_info(&self) -> Vec<(Hidden, usize)> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
|
||||
@@ -946,6 +971,20 @@ fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo])
|
||||
&& a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation)
|
||||
}
|
||||
|
||||
/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read
|
||||
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none.
|
||||
fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 6] {
|
||||
let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
|
||||
[
|
||||
(&mut info.title, read("Title")),
|
||||
(&mut info.author, read("Author")),
|
||||
(&mut info.subject, read("Subject")),
|
||||
(&mut info.keywords, read("Keywords")),
|
||||
(&mut info.creator, read("Creator")),
|
||||
(&mut info.producer, read("Producer")),
|
||||
]
|
||||
}
|
||||
|
||||
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
|
||||
/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page
|
||||
/// images agree with the app and independent viewers. It never changes the source document, and an
|
||||
@@ -1050,7 +1089,7 @@ fn shown_under_redactions(
|
||||
}
|
||||
|
||||
fn render_threads() -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
|
||||
std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1
|
||||
}
|
||||
|
||||
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
|
||||
@@ -1129,6 +1168,10 @@ pub struct BookmarkRow {
|
||||
pub page: Option<usize>,
|
||||
/// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say).
|
||||
pub view: pdfcraft_render::DestView,
|
||||
/// The title's colour (`/C`), `None` for the default black, and its style (`/F`).
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub italic: bool,
|
||||
pub bold: bool,
|
||||
}
|
||||
|
||||
/// One page of the bookmark list, in outline order.
|
||||
@@ -1156,7 +1199,16 @@ fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec<BookmarkRow> {
|
||||
top.1 += 1;
|
||||
}
|
||||
let path: Vec<usize> = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect();
|
||||
rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page, view: item.view });
|
||||
rows.push(BookmarkRow {
|
||||
path,
|
||||
title: item.title.clone(),
|
||||
open: item.open,
|
||||
page: item.page,
|
||||
view: item.view,
|
||||
color: item.color,
|
||||
italic: item.italic,
|
||||
bold: item.bold,
|
||||
});
|
||||
if !item.children.is_empty() {
|
||||
levels.push((&item.children, 0));
|
||||
}
|
||||
@@ -1298,7 +1350,7 @@ impl Protection {
|
||||
(Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"),
|
||||
_ => {}
|
||||
}
|
||||
if [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.is_empty()) {
|
||||
if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) {
|
||||
return bad("passwords can't be empty");
|
||||
}
|
||||
if self.algorithm != pdfcraft_cos::Algorithm::Aes256
|
||||
@@ -1416,6 +1468,17 @@ pub enum Edit {
|
||||
path: Vec<usize>,
|
||||
page: usize,
|
||||
},
|
||||
/// Colour a bookmark's title (`/C`); `None` or black is the default black.
|
||||
SetBookmarkColor {
|
||||
path: Vec<usize>,
|
||||
color: Option<[f64; 3]>,
|
||||
},
|
||||
/// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one.
|
||||
SetBookmarkStyle {
|
||||
path: Vec<usize>,
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
},
|
||||
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
|
||||
/// "Untitled" bookmark (New Bookmarks from Structure).
|
||||
BookmarksFromStructure,
|
||||
@@ -1440,6 +1503,8 @@ pub enum Edit {
|
||||
AddAnnotation(NewAnnotation),
|
||||
/// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size
|
||||
/// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt).
|
||||
/// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape,
|
||||
/// and centred in it (#4077).
|
||||
AddCustomStamp {
|
||||
page: usize,
|
||||
rect: [f64; 4],
|
||||
@@ -1589,6 +1654,18 @@ pub enum Edit {
|
||||
},
|
||||
/// Document Properties ▸ Initial View (and the language and binding).
|
||||
SetInitialView(Box<InitialView>),
|
||||
/// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under
|
||||
/// `name`, made unique; `mime` is the embedded file's `/Subtype` when known.
|
||||
AddAttachment {
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
description: Option<String>,
|
||||
mime: Option<String>,
|
||||
},
|
||||
/// Remove the document-level attachment whose name-tree key is `key`.
|
||||
DeleteAttachment {
|
||||
key: String,
|
||||
},
|
||||
/// Build a tagged structure tree for an untagged, text-only document.
|
||||
AutoTagText,
|
||||
/// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is
|
||||
@@ -1756,6 +1833,16 @@ pub enum Edit {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
/// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its
|
||||
/// style, an image in its box (display space) with its turns, flips and crop. An image's
|
||||
/// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused
|
||||
/// while it is still that picture in this document, else one an added image here already draws
|
||||
/// it from, else the file is embedded.
|
||||
PasteContent {
|
||||
page: usize,
|
||||
content: AddedContent,
|
||||
file: Option<(String, Arc<Vec<u8>>)>,
|
||||
},
|
||||
/// Apply redaction marks (all, or those on `pages`): remove what they cover for good.
|
||||
ApplyRedactions {
|
||||
pages: Option<Vec<usize>>,
|
||||
@@ -1912,11 +1999,15 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Insert pages from {name}",
|
||||
"Insert pages from {name} — 1 field renamed",
|
||||
"Insert pages from {name} — fields renamed",
|
||||
"Attach {name}",
|
||||
"Delete attachment {key}",
|
||||
"Add bookmark",
|
||||
"Rename bookmark",
|
||||
"Delete bookmark",
|
||||
"Move bookmark",
|
||||
"Set bookmark destination",
|
||||
"Colour",
|
||||
"Style",
|
||||
"New bookmarks from structure",
|
||||
"Number pages",
|
||||
"Measure distance",
|
||||
@@ -2006,6 +2097,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Add image",
|
||||
"Edit content",
|
||||
"Delete content",
|
||||
"Paste",
|
||||
"Apply redactions",
|
||||
"Remove redaction marks",
|
||||
"Remove hidden information",
|
||||
@@ -2029,7 +2121,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives
|
||||
/// already worded in the interface's language.
|
||||
pub const BATCH_LABELS: &[&str] =
|
||||
&["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL];
|
||||
&["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"];
|
||||
|
||||
impl Edit {
|
||||
/// Label for the Edit menu and history ("Undo Rotate pages").
|
||||
@@ -2060,6 +2152,9 @@ impl Edit {
|
||||
Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"),
|
||||
Edit::MoveBookmark { .. } => Label::of("Move bookmark"),
|
||||
Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"),
|
||||
// The bookmark Style and Colour menus' own names, already said in every language.
|
||||
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
|
||||
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
|
||||
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
|
||||
Edit::NumberPages { .. } => Label::of("Number pages"),
|
||||
// What was measured is part of the wording too, for the same reason.
|
||||
@@ -2104,6 +2199,8 @@ impl Edit {
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
|
||||
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
|
||||
Edit::SetInitialView(_) => Label::of("Change initial view"),
|
||||
Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()),
|
||||
Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()),
|
||||
Edit::AutoTagText => Label::of("Automatically tag text"),
|
||||
Edit::SetAltText { .. } => Label::of("Set alternate text"),
|
||||
Edit::AutoTagFormFields => Label::of("Tag form fields"),
|
||||
@@ -2145,6 +2242,7 @@ impl Edit {
|
||||
Edit::UpdateContent { .. } => Label::of("Edit content"),
|
||||
Edit::DeleteContent { .. } => Label::of("Delete content"),
|
||||
Edit::ReplaceImage { .. } => Label::of("Replace image"),
|
||||
Edit::PasteContent { .. } => Label::of("Paste"),
|
||||
Edit::ApplyRedactions { .. } => Label::of("Apply redactions"),
|
||||
Edit::ClearRedactions => Label::of("Remove redaction marks"),
|
||||
Edit::RemoveHidden { .. } => Label::of("Remove hidden information"),
|
||||
@@ -2227,6 +2325,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::DeleteBookmark { .. }
|
||||
| Edit::MoveBookmark { .. }
|
||||
| Edit::SetBookmarkPage { .. }
|
||||
| Edit::SetBookmarkColor { .. }
|
||||
| Edit::SetBookmarkStyle { .. }
|
||||
| Edit::BookmarksFromStructure
|
||||
| Edit::NumberPages { .. } => {
|
||||
if p.assemble() {
|
||||
@@ -2274,6 +2374,15 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
Err(EditError::NotPermitted("changing security"))
|
||||
}
|
||||
}
|
||||
// Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted
|
||||
// document it is a change of security (#3921).
|
||||
Edit::ConvertPdfA { .. } => {
|
||||
if unrestricted(p) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids"))
|
||||
}
|
||||
}
|
||||
Edit::SetInfo { .. }
|
||||
| Edit::AddHeaderFooter { .. }
|
||||
| Edit::AddWatermark { .. }
|
||||
@@ -2287,6 +2396,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::MoveObjects { .. }
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::ClearRedactions
|
||||
| Edit::RemoveHidden { .. }
|
||||
| Edit::ImportData { .. }
|
||||
@@ -2304,6 +2414,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::MoveInTabOrder { .. }
|
||||
| Edit::SetCalculationOrder { .. }
|
||||
| Edit::SetInitialView(_)
|
||||
| Edit::AddAttachment { .. }
|
||||
| Edit::DeleteAttachment { .. }
|
||||
| Edit::AutoTagText
|
||||
| Edit::SetAltText { .. }
|
||||
| Edit::AutoTagFormFields
|
||||
@@ -2314,7 +2426,6 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::AddOcrText { .. }
|
||||
| Edit::SetDocumentScript { .. }
|
||||
| Edit::SetFieldScript { .. }
|
||||
| Edit::ConvertPdfA { .. }
|
||||
| Edit::RemoveStandardId { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
@@ -2464,6 +2575,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?;
|
||||
}
|
||||
Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?,
|
||||
Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?,
|
||||
Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?,
|
||||
Edit::BookmarksFromStructure => {
|
||||
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
|
||||
.into_iter()
|
||||
@@ -2484,12 +2597,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
|
||||
let natural = rect[2] == rect[0] && rect[3] == rect[1];
|
||||
let rotation = pdfcraft_organize::page_rotation(doc, *page)?;
|
||||
// The picture as displayed: a quarter turn swaps its sides in user space.
|
||||
let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) };
|
||||
let rect = if natural {
|
||||
let k = (200.0 / sw.max(sh)).min(1.0);
|
||||
let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) };
|
||||
let (w, h) = (dw * k, dh * k);
|
||||
[rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0]
|
||||
} else {
|
||||
*rect
|
||||
fit_picture(*rect, dw, dh)
|
||||
};
|
||||
let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) };
|
||||
let style = pdfcraft_annot::Style::default_for(&shape);
|
||||
@@ -2508,6 +2623,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::EraseInk { page, index, path, radius } => {
|
||||
pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?;
|
||||
}
|
||||
Edit::AddAttachment { name, bytes, description, mime } => {
|
||||
pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?;
|
||||
}
|
||||
Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?,
|
||||
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
|
||||
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
|
||||
// the strikeout's `/NM` (#2666).
|
||||
@@ -2529,10 +2648,23 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
|
||||
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
|
||||
}
|
||||
Edit::SetFieldValue { name, value } => match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js)?,
|
||||
None => pdfcraft_forms::set_value(doc, name, value)?,
|
||||
},
|
||||
Edit::SetFieldValue { name, value } => {
|
||||
let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js),
|
||||
None => pdfcraft_forms::set_value(doc, name, value),
|
||||
};
|
||||
match set(doc, value) {
|
||||
Ok(()) => {}
|
||||
// A date the field's format doesn't read may be typed by another of an XFA date
|
||||
// field's patterns: it is kept in the first. Only a format refusal is tried
|
||||
// again: it comes before any of the field's scripts run, so they run once.
|
||||
Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) {
|
||||
Some(typed) => set(doc, &typed)?,
|
||||
None => return Err(refused.into()),
|
||||
},
|
||||
Err(refused) => return Err(refused.into()),
|
||||
}
|
||||
}
|
||||
Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?,
|
||||
None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?,
|
||||
@@ -2739,6 +2871,28 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
let (image, _) = embed_image(doc, name, bytes)?;
|
||||
pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?;
|
||||
}
|
||||
Edit::PasteContent { page, content, file } => {
|
||||
let content = match content {
|
||||
AddedContent::Text(_) => content.clone(),
|
||||
AddedContent::Image(img) => {
|
||||
let Some((name, bytes)) = file else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into())));
|
||||
};
|
||||
// The same picture in this document (copied here, pasted here before, or an
|
||||
// identical file): drawn again from the one image object rather than stored twice.
|
||||
let image = if copy_image::is_picture(doc, img.image, bytes) {
|
||||
img.image
|
||||
} else {
|
||||
match copy_image::added_picture(doc, bytes) {
|
||||
Some(image) => image,
|
||||
None => embed_image(doc, name, bytes)?.0,
|
||||
}
|
||||
};
|
||||
AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() })
|
||||
}
|
||||
};
|
||||
pdfcraft_edit::add_content(doc, *page, &content)?;
|
||||
}
|
||||
Edit::ApplyRedactions { pages } => {
|
||||
cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays;
|
||||
}
|
||||
@@ -2863,6 +3017,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
|
||||
locked: s.locked,
|
||||
intent: s.intent,
|
||||
fill_sign: s.fill_sign,
|
||||
no_rotate: s.no_rotate,
|
||||
points: s.points,
|
||||
})
|
||||
.collect()
|
||||
@@ -2905,6 +3060,19 @@ fn renderer_password(bytes: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as it may be written to a file (#3926): a copy without the values of text fields
|
||||
/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231
|
||||
/// (a note to the Password flag): "it is imperative that PDF processors never store the value of
|
||||
/// the text field in the PDF file if this flag is set". The open document keeps them in memory.
|
||||
fn writable(cos: &pdfcraft_cos::Document) -> Result<std::borrow::Cow<'_, pdfcraft_cos::Document>, EditError> {
|
||||
if !pdfcraft_forms::has_password_values(cos) {
|
||||
return Ok(std::borrow::Cow::Borrowed(cos));
|
||||
}
|
||||
let mut copy = cos.clone();
|
||||
pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
Ok(std::borrow::Cow::Owned(copy))
|
||||
}
|
||||
|
||||
/// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error
|
||||
/// message, so one bad file or edit can't take the app and its other documents down. It is a
|
||||
/// safety net for bugs, not a substitute for returning errors.
|
||||
@@ -3666,7 +3834,7 @@ impl Session {
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
info: DocInfo,
|
||||
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>,
|
||||
@@ -3698,6 +3866,15 @@ impl Session {
|
||||
};
|
||||
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
|
||||
// The inspection reads only `/Info`; a file stating its title or author only in XMP still
|
||||
// shows them (#2373).
|
||||
if let Some(e) = &editor {
|
||||
for (field, value) in info_fields(&mut info, &e.cos) {
|
||||
if field.is_none() {
|
||||
*field = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
let display = display_bytes(editor.as_mut(), &bytes);
|
||||
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
|
||||
let (form, adopted) = editor
|
||||
@@ -4063,15 +4240,9 @@ impl Session {
|
||||
editor.cos.bytes().clone()
|
||||
};
|
||||
if scope == Scope::Metadata {
|
||||
// Read the way the full inspection reads them (NULs and spaces trimmed, empty is
|
||||
// none), so editing one entry doesn't change how the others display.
|
||||
let info = |key| pdfcraft_organize::info(&editor.cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
|
||||
doc.info.title = info("Title");
|
||||
doc.info.author = info("Author");
|
||||
doc.info.subject = info("Subject");
|
||||
doc.info.keywords = info("Keywords");
|
||||
doc.info.creator = info("Creator");
|
||||
doc.info.producer = info("Producer");
|
||||
for (field, value) in info_fields(&mut doc.info, &editor.cos) {
|
||||
*field = value;
|
||||
}
|
||||
doc.info.custom = pdfcraft_organize::custom_info(&editor.cos);
|
||||
doc.info.file_size = bytes.len();
|
||||
#[cfg(test)]
|
||||
@@ -4132,10 +4303,22 @@ impl Session {
|
||||
/// Which derived lists a content-only edit can change: added content (`None`, from
|
||||
/// undo/redo, re-reads both: always correct, rarely paid).
|
||||
fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) {
|
||||
if let Some(Edit::Batch { edits, .. }) = edit {
|
||||
// A batch of content edits (from a script, say) changes what its parts change.
|
||||
return edits.iter().fold((false, false), |(added, marks), part| {
|
||||
let (a, m) = Self::content_refresh_lists(Some(part));
|
||||
(added || a, marks || m)
|
||||
});
|
||||
}
|
||||
let touches_added = edit.is_none_or(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. }
|
||||
Edit::AddText { .. }
|
||||
| Edit::AddImage { .. }
|
||||
| Edit::UpdateContent { .. }
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
|
||||
});
|
||||
let touches_marks = edit.is_none_or(|e| {
|
||||
@@ -4232,6 +4415,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
note_warnings(&mut info.warnings, &doc.xfa_warnings);
|
||||
// As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP
|
||||
// are filled in again after every full refresh.
|
||||
for (field, value) in info_fields(&mut info, &editor.cos) {
|
||||
if field.is_none() {
|
||||
*field = value;
|
||||
}
|
||||
}
|
||||
doc.info = info;
|
||||
doc.fonts_stale = false;
|
||||
let mut form = pdfcraft_forms::fields(&editor.cos);
|
||||
@@ -4270,12 +4460,13 @@ impl Session {
|
||||
// file so that revision stays in the chain. A full rewrite (redaction, new encryption,
|
||||
// a reconstructed file) still writes the current graph on its own.
|
||||
let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty();
|
||||
let cos = writable(&editor.cos)?;
|
||||
guard(|| {
|
||||
if on_saved || rewrite {
|
||||
write_incremental(&editor.cos, &opts)
|
||||
write_incremental(&cos, &opts)
|
||||
} else {
|
||||
let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?;
|
||||
base.overlay_changes_from(&editor.cos)?;
|
||||
base.overlay_changes_from(&cos)?;
|
||||
write_incremental(&base, &opts)
|
||||
}
|
||||
})
|
||||
@@ -4292,7 +4483,8 @@ impl Session {
|
||||
}
|
||||
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||||
let cos = writable(&editor.cos)?;
|
||||
guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||||
}
|
||||
|
||||
/// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this
|
||||
@@ -4313,8 +4505,11 @@ impl Session {
|
||||
// An encrypted file needs the password it is protected with now (the owner
|
||||
// password when known, so saving never downgrades this session's rights).
|
||||
let keys = editor.keys.clone();
|
||||
// The saved file carries no Password-field values (#3943); the open document keeps them.
|
||||
let typed = pdfcraft_forms::password_values(&editor.cos);
|
||||
editor.cos =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
editor.saved = bytes;
|
||||
editor.saved_keys = keys;
|
||||
editor.unsaved_file = false;
|
||||
@@ -4647,7 +4842,7 @@ impl Session {
|
||||
pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec<Vec<(String, String)>> {
|
||||
let docs: Vec<Option<pdfcraft_cos::Document>> =
|
||||
sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect();
|
||||
let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(|d| d.as_ref()).collect();
|
||||
let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect();
|
||||
let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter();
|
||||
// One preview per source, in step with it: a file that cannot be opened has none.
|
||||
docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect()
|
||||
@@ -4846,7 +5041,7 @@ impl Session {
|
||||
let numbers: Vec<Option<f32>> = numbers.iter().map(|n| n.map(|n| n as f32)).collect();
|
||||
pdfcraft_render::DestView::from_operands(Some(&kind), &numbers)
|
||||
});
|
||||
BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view }
|
||||
BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold }
|
||||
})
|
||||
.collect();
|
||||
return Some(BookmarkPage { rows, next: page.next, truncated: false });
|
||||
@@ -4900,9 +5095,12 @@ impl Session {
|
||||
}
|
||||
|
||||
/// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size.
|
||||
/// Like a save, a new file never carries a typed Password-field value (#3926): Extract and
|
||||
/// Split copy the fields of the pages they take.
|
||||
fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||||
let doc = writable(doc)?;
|
||||
write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||||
}
|
||||
|
||||
/// Whether a changed document still needs a recovery snapshot. This does not consume it.
|
||||
@@ -4916,12 +5114,23 @@ impl Session {
|
||||
let mut out = Vec::new();
|
||||
for d in &mut self.docs {
|
||||
if d.dirty && d.generation != d.snapshot_generation {
|
||||
// The working bytes hold typed Password-field values (#3926): a recovery file is
|
||||
// written from a copy without them, and none is written if that copy fails.
|
||||
let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) {
|
||||
Some(e) => match writable(&e.cos)
|
||||
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
|
||||
{
|
||||
Ok(b) => Arc::new(b),
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => d.bytes.clone(),
|
||||
};
|
||||
d.snapshot_generation = d.generation;
|
||||
out.push(RecoverySnapshot {
|
||||
doc: d.id,
|
||||
name: d.name.clone(),
|
||||
path: d.path.clone(),
|
||||
bytes: d.bytes.clone(),
|
||||
bytes,
|
||||
encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()),
|
||||
});
|
||||
}
|
||||
@@ -5075,7 +5284,9 @@ impl Session {
|
||||
if opts.date.is_empty() {
|
||||
opts.date = self.signing_date();
|
||||
}
|
||||
Ok(Arc::new(pdfcraft_sign::sign(&editor.cos, id, &opts)?))
|
||||
// A typed Password-field value is not signed into the file (#3926), as it isn't saved.
|
||||
let cos = writable(&editor.cos)?;
|
||||
Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?))
|
||||
}
|
||||
|
||||
/// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by
|
||||
@@ -5092,7 +5303,8 @@ impl Session {
|
||||
if opts.date.is_empty() {
|
||||
opts.date = self.signing_date();
|
||||
}
|
||||
let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?);
|
||||
let cos = writable(&editor.cos)?;
|
||||
let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?);
|
||||
// Checked like a save before anyone writes it (#2166).
|
||||
self.check_save(doc, &signed)?;
|
||||
Ok(signed)
|
||||
@@ -5104,7 +5316,8 @@ impl Session {
|
||||
let d = self.get(doc).ok_or(EditError::NoDocument)?;
|
||||
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
|
||||
let date = if date.is_empty() { self.signing_date() } else { date };
|
||||
let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?);
|
||||
let cos = writable(&editor.cos)?;
|
||||
let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?);
|
||||
self.check_save(doc, &stamped)?;
|
||||
Ok(stamped)
|
||||
}
|
||||
@@ -5114,7 +5327,8 @@ impl Session {
|
||||
pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let d = self.get(doc).ok_or(EditError::NoDocument)?;
|
||||
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
|
||||
let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?);
|
||||
let cos = writable(&editor.cos)?;
|
||||
let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?);
|
||||
self.check_save(doc, &embedded)?;
|
||||
Ok(embedded)
|
||||
}
|
||||
@@ -5172,6 +5386,29 @@ fn embed_image(
|
||||
pdfcraft_create::image_xobject(doc, name, bytes)
|
||||
}
|
||||
|
||||
/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its
|
||||
/// shape in a box of any other shape. A box that is already that shape (to within rounding) is
|
||||
/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite
|
||||
/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse.
|
||||
fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] {
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let usable = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !(usable(bw) && usable(bh) && usable(w) && usable(h)) {
|
||||
return rect;
|
||||
}
|
||||
let k = (bw / w).min(bh / h);
|
||||
let (fw, fh) = (w * k, h * k);
|
||||
if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 {
|
||||
return rect;
|
||||
}
|
||||
let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0);
|
||||
[cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0]
|
||||
}
|
||||
|
||||
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
|
||||
fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
|
||||
let head = &f.bytes[..f.bytes.len().min(1024)];
|
||||
|
||||
@@ -176,7 +176,7 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool {
|
||||
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
|
||||
/// Relative paths are based on the source document, which should be an absolute path; an
|
||||
/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are
|
||||
/// refused ([`craft_docpath::resolve`]).
|
||||
/// refused ([`craft_docpath::resolve_absolute`]).
|
||||
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
|
||||
if raw.is_empty() || raw.trim().is_empty() {
|
||||
return Err(RemoteDocumentPathError::Empty);
|
||||
@@ -197,12 +197,14 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<
|
||||
return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url));
|
||||
}
|
||||
}
|
||||
let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?;
|
||||
// A relative result would be read against the working directory.
|
||||
if !path.is_absolute() {
|
||||
return Err(RemoteDocumentPathError::MissingSourcePath);
|
||||
}
|
||||
Ok(path)
|
||||
craft_docpath::resolve_absolute(raw, source).map_err(|r| match r {
|
||||
// A path that resolves but not to an absolute one needed a source with an absolute path. With
|
||||
// such a source, it is a path this platform cannot open (`C:\x.pdf` on Unix).
|
||||
craft_docpath::Refused::Invalid if !source.is_some_and(Path::is_absolute) && craft_docpath::resolve(raw, source).is_ok() => {
|
||||
RemoteDocumentPathError::MissingSourcePath
|
||||
}
|
||||
r => RemoteDocumentPathError::NotLocal(r),
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
|
||||
@@ -512,6 +514,8 @@ mod tests {
|
||||
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
|
||||
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
|
||||
assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used");
|
||||
#[cfg(unix)]
|
||||
assert_eq!(resolve(Some(source), "C:\\x.pdf"), Err(E::NotLocal(craft_docpath::Refused::Invalid)), "a Windows path on Unix needs no source");
|
||||
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
|
||||
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
|
||||
for url in
|
||||
@@ -520,6 +524,8 @@ mod tests {
|
||||
assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}");
|
||||
}
|
||||
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
|
||||
#[cfg(windows)]
|
||||
assert_eq!(resolve(Some(source), "folder/a:b.pdf"), Err(E::NotLocal(craft_docpath::Refused::Invalid)));
|
||||
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ pub struct OcrSettings {
|
||||
/// text" and skips them). When `false`, such pages are read too (a picture on a page that
|
||||
/// also has text, #1028) and only the words that aren't already there are added.
|
||||
pub skip_text_pages: bool,
|
||||
/// What counts as the page's text when `skip_text_pages` is on: only its own (page content
|
||||
/// and form fields). Comments and text boxes are left out, and are not drawn into the picture
|
||||
/// that is read, so a scan with a few comments on it is still a scan. `false`: everything
|
||||
/// the page shows.
|
||||
pub native_text_only: bool,
|
||||
}
|
||||
|
||||
/// A recognised word whose box is at least this much covered by the page's own text is that
|
||||
@@ -51,7 +56,7 @@ fn covered(word: [f32; 4], text: &[[f32; 4]]) -> f32 {
|
||||
|
||||
impl Default for OcrSettings {
|
||||
fn default() -> Self {
|
||||
OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true }
|
||||
OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true, native_text_only: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +80,7 @@ impl OcrPage {
|
||||
/// The recogniser, loaded once (it takes a moment) and shared.
|
||||
pub fn engine() -> Result<Arc<Ocr>, String> {
|
||||
static OCR: Mutex<Option<Arc<Ocr>>> = Mutex::new(None);
|
||||
let mut slot = OCR.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = OCR.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(o) = slot.as_ref() {
|
||||
return Ok(o.clone());
|
||||
}
|
||||
@@ -105,7 +110,8 @@ impl OcrJob {
|
||||
/// Read the pages. `progress(done, total)` is called before each page; returning `false`
|
||||
/// stops (the pages read so far are returned).
|
||||
pub fn run(self, ocr: &Ocr, mut progress: impl FnMut(usize, usize) -> bool) -> Vec<OcrPage> {
|
||||
let config = RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let config =
|
||||
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
|
||||
let mut r = PageRenderer::new(self.bytes.clone(), config);
|
||||
let mut out = Vec::new();
|
||||
let total = self.pages.len();
|
||||
|
||||
@@ -68,7 +68,11 @@ impl Session {
|
||||
cos: editor.cos.clone(),
|
||||
settings: settings.clone(),
|
||||
discard: discard.to_vec(),
|
||||
opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() },
|
||||
opts: SaveOptions {
|
||||
mod_date: self.now().map(pdfcraft_cos::pdf_date),
|
||||
object_streams: settings.effective_object_streams(),
|
||||
..SaveOptions::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -83,6 +87,8 @@ impl OptimizeJob {
|
||||
|
||||
fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
|
||||
let OptimizeJob { mut cos, settings, discard, opts } = self;
|
||||
// Like every file written, without the values of Password-flag fields (#3926).
|
||||
pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
|
||||
step(OptimizeStage::Discarding)?;
|
||||
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
|
||||
|
||||
+1007
-2
File diff suppressed because it is too large.
Load diff
@@ -74,5 +74,5 @@ pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
|
||||
files
|
||||
}
|
||||
fn font_extension(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
|
||||
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
|
||||
}
|
||||
@@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> {
|
||||
XfaEffect::Print => Some("print"),
|
||||
XfaEffect::SaveAs => Some("save"),
|
||||
XfaEffect::LaunchUrl(_) => Some("open a link"),
|
||||
XfaEffect::ShowPanel(_) => Some("show a side panel"),
|
||||
XfaEffect::SetFocus(_) => Some("move the focus"),
|
||||
_ => None,
|
||||
}
|
||||
@@ -532,7 +533,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
} else {
|
||||
Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
|
||||
};
|
||||
if listed.as_ref().is_some_and(|l| l.is_empty()) {
|
||||
if listed.as_ref().is_some_and(Vec::is_empty) {
|
||||
return Ok(());
|
||||
}
|
||||
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
|
||||
@@ -628,6 +629,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
XfaEffect::Print => self.run.out.requests.push(Request::Print),
|
||||
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
|
||||
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
|
||||
XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)),
|
||||
XfaEffect::SetFocus(som) => {
|
||||
if let Some(name) = self.by_som().get(som).cloned() {
|
||||
self.run.out.requests.push(Request::Focus(name));
|
||||
@@ -858,6 +860,28 @@ pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template,
|
||||
r.finish().map(|_| ())
|
||||
}
|
||||
|
||||
/// A text value typed into the laid-out XFA date field `name` by another of its edit picture's
|
||||
/// patterns, written in the first ([`pdfcraft_xfa::typed_date`]); `None` for anything else.
|
||||
/// `tpl` is the template the scripts use, when they run; otherwise it is read from `doc`.
|
||||
pub(crate) fn typed_date(
|
||||
doc: &pdfcraft_cos::Document,
|
||||
tpl: Option<&Template>,
|
||||
name: &str,
|
||||
value: &pdfcraft_forms::FieldValue,
|
||||
) -> Option<pdfcraft_forms::FieldValue> {
|
||||
let pdfcraft_forms::FieldValue::Text(text) = value else { return None };
|
||||
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find_map(|(som, n)| (n == name).then_some(som))?;
|
||||
let read;
|
||||
let tpl = match tpl {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
read = pdfcraft_xfa::template_of(doc).ok()?;
|
||||
&read
|
||||
}
|
||||
};
|
||||
pdfcraft_xfa::typed_date(tpl, &som, text).map(pdfcraft_forms::FieldValue::Text)
|
||||
}
|
||||
|
||||
/// A button's click script (or any event, by SOM path and activity), then calculations.
|
||||
pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> {
|
||||
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
|
||||
|
||||
@@ -8,5 +8,6 @@ mod document_fonts;
|
||||
mod form_rotation;
|
||||
mod marked_content_isolation;
|
||||
mod ocr_edit;
|
||||
mod office_export_images;
|
||||
mod private_image_resize;
|
||||
mod private_text_glyphs;
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Office export of pages that draw one image many times.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::Session;
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// One image drawn many times is prepared once: every placement shares its encoded bytes.
|
||||
#[test]
|
||||
fn an_image_drawn_many_times_is_encoded_once() {
|
||||
let placements = 500;
|
||||
let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements);
|
||||
let bytes = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(),
|
||||
]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let pages = s.get(id).unwrap().export_pages();
|
||||
let images = &pages[0].images;
|
||||
assert_eq!(images.len(), placements);
|
||||
let first = images[0].bytes.as_ptr();
|
||||
assert!(!images[0].bytes.is_empty());
|
||||
assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image");
|
||||
}
|
||||
+85
-16
@@ -37,7 +37,8 @@ pub struct Block {
|
||||
pub struct Image {
|
||||
/// "png" or "jpg".
|
||||
pub ext: &'static str,
|
||||
pub bytes: Vec<u8>,
|
||||
/// Shared: a picture drawn in many places is held once.
|
||||
pub bytes: std::sync::Arc<[u8]>,
|
||||
pub rect: [f64; 4],
|
||||
/// Width and height before rotation, in points.
|
||||
pub size: [f64; 2],
|
||||
@@ -245,7 +246,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
let mut edges: Vec<f64> = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect();
|
||||
edges.sort_by(|a, b| a.total_cmp(b));
|
||||
edges.sort_by(f64::total_cmp);
|
||||
edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
|
||||
let ncols = edges.len();
|
||||
let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS;
|
||||
@@ -255,7 +256,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
|
||||
}
|
||||
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
|
||||
let mut rows_out = Vec::new();
|
||||
let mut slots: Vec<Option<Cell>> = vec![None; ncols];
|
||||
let mut slots: Vec<Option<(Cell, Vec<usize>)>> = vec![None; ncols];
|
||||
for row in run.drain(..) {
|
||||
for slot in slots.iter_mut() {
|
||||
*slot = None;
|
||||
@@ -265,20 +266,28 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
|
||||
let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue };
|
||||
let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1;
|
||||
let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span };
|
||||
match &mut slots[slot] {
|
||||
Some(c) => {
|
||||
match slots.get_mut(slot) {
|
||||
Some(Some((c, sources))) => {
|
||||
c.text.push(' ');
|
||||
c.text.push_str(&cell.text);
|
||||
sources.push(bi);
|
||||
}
|
||||
None => slots[slot] = Some(cell),
|
||||
Some(empty @ None) => *empty = Some((cell, vec![bi])),
|
||||
None => continue,
|
||||
}
|
||||
consumed[bi] = true;
|
||||
}
|
||||
let mut materialized = Vec::with_capacity(ncols);
|
||||
let mut column = 0;
|
||||
while column < ncols {
|
||||
match slots[column].take() {
|
||||
Some(mut cell) => {
|
||||
match slots.get_mut(column).and_then(Option::take) {
|
||||
Some((mut cell, sources)) => {
|
||||
// A spanning cell can skip later slots. Only consume source blocks
|
||||
// when their text is emitted, leaving covered text in paragraph flow.
|
||||
for bi in sources {
|
||||
if let Some(taken) = consumed.get_mut(bi) {
|
||||
*taken = true;
|
||||
}
|
||||
}
|
||||
cell.span = cell.span.min(ncols - column).max(1);
|
||||
column += cell.span;
|
||||
materialized.push(cell);
|
||||
@@ -312,7 +321,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
|
||||
let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec<f64> {
|
||||
let mut es: Vec<f64> =
|
||||
run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect();
|
||||
es.sort_by(|a, b| a.total_cmp(b));
|
||||
es.sort_by(f64::total_cmp);
|
||||
es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
|
||||
es
|
||||
};
|
||||
@@ -516,17 +525,18 @@ fn docx_table(t: &Table) -> String {
|
||||
s.push_str("</w:tblGrid>");
|
||||
for row in &t.rows {
|
||||
s.push_str("<w:tr>");
|
||||
let mut column = 0usize;
|
||||
for c in row {
|
||||
let span = if c.span > 1 { format!("<w:gridSpan w:val=\"{}\"/>", c.span) } else { String::new() };
|
||||
let w: i64 = widths.iter().take(c.span.min(widths.len())).sum();
|
||||
let w: i64 = widths.iter().skip(column).take(c.span).sum();
|
||||
s.push_str(&format!(
|
||||
"<w:tc><w:tcPr><w:tcW w:w=\"{w}\" w:type=\"dxa\"/>{span}</w:tcPr><w:p>{}</w:p></w:tc>",
|
||||
run_xml(&c.text, c.size, c.bold, c.italic, c.color)
|
||||
));
|
||||
column = column.saturating_add(c.span);
|
||||
}
|
||||
// Pad the grid so every row covers all columns (Word rejects short rows).
|
||||
let used: usize = row.iter().map(|c| c.span).sum();
|
||||
for _ in used..t.cols.len() {
|
||||
for _ in column..t.cols.len() {
|
||||
s.push_str("<w:tc><w:tcPr><w:tcW w:w=\"0\" w:type=\"auto\"/></w:tcPr><w:p/></w:tc>");
|
||||
}
|
||||
s.push_str("</w:tr>");
|
||||
@@ -997,7 +1007,7 @@ mod tests {
|
||||
],
|
||||
images: vec![Image {
|
||||
ext: "png",
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
|
||||
rect: [72.0, 450.0, 272.0, 600.0],
|
||||
size: [200.0, 150.0],
|
||||
rotation: 0.0,
|
||||
@@ -1039,6 +1049,27 @@ mod tests {
|
||||
assert!(xml.contains("<w:insideH w:val=\"nil\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_table_cell_widths_follow_the_columns_they_span() {
|
||||
let cell = |text: &str, span| Cell { text: text.into(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span };
|
||||
let table = Table {
|
||||
rect: [0.0, 0.0, 300.0, 100.0],
|
||||
cols: vec![0.0, 50.0, 150.0],
|
||||
rows: vec![
|
||||
vec![cell("First", 1), cell("Second", 1), cell("Third", 1)],
|
||||
vec![cell("First", 1), cell("Last two", 2)],
|
||||
vec![cell("First two", 2), cell("Third", 1)],
|
||||
vec![cell("All", 3)],
|
||||
vec![cell("First", 1)],
|
||||
],
|
||||
};
|
||||
let xml = docx_table(&table);
|
||||
assert!(xml.contains("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
|
||||
let widths: Vec<Vec<&str>> =
|
||||
xml.split("<w:tr>").skip(1).map(|row| row.split("<w:tcW w:w=\"").skip(1).map(|cell| cell.split('"').next().unwrap()).collect()).collect();
|
||||
assert_eq!(widths, vec![vec!["1000", "2000", "3000"], vec!["1000", "5000"], vec!["3000", "3000"], vec!["6000"], vec!["1000", "0", "0"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_keeps_a_page_with_a_clear_image_and_big_title_in_flow() {
|
||||
// page() has a picture between paragraphs and a 24 pt title over 11 pt body text, but
|
||||
@@ -1391,6 +1422,40 @@ mod tests {
|
||||
assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}");
|
||||
}
|
||||
|
||||
/// A continuation can drift into a grid column covered by the preceding cell's span.
|
||||
/// It must remain available as paragraph text if that slot never becomes an output cell.
|
||||
#[test]
|
||||
fn table_spans_do_not_consume_text_they_do_not_emit() {
|
||||
let mut blocks = vec![
|
||||
Block { size: 12.0, ..block("ABCDEFGHIJK", 72.1, 697.0, 151.3, 710.8) },
|
||||
Block { size: 12.0, color: [0.0, 0.0, 1.0], ..block("LOST", 150.4, 697.0, 179.2, 710.8) },
|
||||
Block { size: 12.0, ..block("R0", 250.0, 697.0, 264.4, 710.8) },
|
||||
];
|
||||
for row in 1..=4 {
|
||||
let y = 697.0 - f64::from(row) * 50.0;
|
||||
for (text, left, right) in [(format!("L{row}"), 72.1, 86.5), (format!("M{row}"), 146.4, 160.8), (format!("R{row}"), 250.0, 264.4)] {
|
||||
blocks.push(Block { size: 12.0, ..block(&text, left, y, right, y + 13.8) });
|
||||
}
|
||||
}
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
|
||||
let (tables, consumed) = tables(&p.blocks);
|
||||
assert_eq!(tables.len(), 1);
|
||||
assert_eq!(tables[0].rows[0][0].span, 2);
|
||||
assert!(!consumed[1], "a covered slot did not emit its text");
|
||||
assert!(consumed.iter().enumerate().all(|(index, used)| index == 1 || *used));
|
||||
let outputs = [
|
||||
html(std::slice::from_ref(&p), "Continuations"),
|
||||
part(&docx(std::slice::from_ref(&p), "Continuations"), "word/document.xml"),
|
||||
rtf(std::slice::from_ref(&p)),
|
||||
part(&pptx(std::slice::from_ref(&p), "Continuations"), "ppt/slides/slide1.xml"),
|
||||
];
|
||||
for output in outputs {
|
||||
for b in &p.blocks {
|
||||
assert_eq!(output.matches(&b.text).count(), 1, "{} must survive exactly once", b.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table
|
||||
/// around it stays a table.
|
||||
#[test]
|
||||
@@ -1406,6 +1471,10 @@ mod tests {
|
||||
let h = html(std::slice::from_ref(&p), "Prizes");
|
||||
assert!(h.contains("<table>"), "the prize table was lost: {h}");
|
||||
assert!(h.contains("30,000") && h.contains("15,000"), "{h}");
|
||||
let (tables, consumed) = tables(&p.blocks);
|
||||
assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells");
|
||||
let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect();
|
||||
assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1439,7 +1508,7 @@ mod tests {
|
||||
/// file in the package, with a content type; JPEG pictures too, inline or placed.
|
||||
#[test]
|
||||
fn word_pictures_resolve_to_media_in_the_package() {
|
||||
let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() };
|
||||
let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() };
|
||||
for placed in [false, true] {
|
||||
let mut p = page();
|
||||
p.images = vec![jpeg.clone()];
|
||||
@@ -1466,7 +1535,7 @@ mod tests {
|
||||
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
|
||||
let mut p = page();
|
||||
let one = p.images[0].clone();
|
||||
p.images = vec![Image { bytes: Vec::new(), ..one.clone() }];
|
||||
p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }];
|
||||
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
|
||||
let zip = docx(&[p], "T");
|
||||
let rels = part(&zip, "word/_rels/document.xml.rels");
|
||||
|
||||
@@ -391,7 +391,7 @@ mod tests {
|
||||
],
|
||||
images: vec![Image {
|
||||
ext: "png",
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
|
||||
rect: [72.0, 150.0, 300.0, 300.0],
|
||||
size: [228.0, 150.0],
|
||||
rotation: 0.0,
|
||||
@@ -619,7 +619,13 @@ mod tests {
|
||||
blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64));
|
||||
}
|
||||
let images = (0..40)
|
||||
.map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 })
|
||||
.map(|k| Image {
|
||||
ext: "png",
|
||||
bytes: [0; 4].as_slice().into(),
|
||||
rect: [0.0, -1e300, 1e-300, 1e300 - k as f64],
|
||||
size: [1.0, 1.0],
|
||||
rotation: 0.0,
|
||||
})
|
||||
.collect();
|
||||
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None };
|
||||
let bytes = pptx(&[p], "Hostile");
|
||||
|
||||
+10
-8
@@ -7,6 +7,9 @@
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "src/cjk_scripts.rs"]
|
||||
mod cjk_scripts;
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
@@ -27,10 +30,6 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Family-name prefixes of the faces AGENTS.md §1.1 forbids: Adobe's Source families and Noto
|
||||
/// CJK, which is Source Han under another name. The test in `src/craft.rs` has the same list.
|
||||
const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"];
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
@@ -43,10 +42,13 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
// AGENTS.md §1.1 bars Adobe's type designs whatever their licence, so a checkout that
|
||||
// carries one (craft-fonts has Noto Sans CJK, which is Source Han) never embeds it.
|
||||
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md §1.1)");
|
||||
// Whole CJK faces aren't embedded for size; interface CJK comes from cut faces once the app
|
||||
// moves to craft-fonts-build (storytold/craft-libs#213).
|
||||
let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script);
|
||||
if cjk
|
||||
&& !cjk_scripts::WHOLE_CJK_FAMILIES.contains(family)
|
||||
&& std::fs::metadata(dir.join(file)).is_ok_and(|m| m.len() > cjk_scripts::MAX_OTHER_CJK_BYTES)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Arabic interface faces are small, so the web build keeps them too.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! The rule `build.rs` follows for CJK faces: which manifest script tags are CJK, which families
|
||||
//! are embedded whole, and the size cap for the others. The build script includes this file with
|
||||
//! `#[path]` and the crate's tests use it as a module, so the two can't drift.
|
||||
|
||||
/// The CJK families embedded whole: the Japanese faces documents and the renderer use.
|
||||
pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", "BIZ UDMincho"];
|
||||
|
||||
/// Any other CJK face over this size is left out.
|
||||
pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20;
|
||||
|
||||
/// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included.
|
||||
const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"];
|
||||
|
||||
/// Whether a craft-fonts manifest script tag is CJK. A tag can carry a region after the code
|
||||
/// (craft-fonts tags Noto Sans HK UI `Hant-HK`), so only the part before `-` is compared.
|
||||
pub fn is_cjk_script(tag: &str) -> bool {
|
||||
let code = tag.trim().split('-').next().unwrap_or_default();
|
||||
CJK_SCRIPTS.contains(&code)
|
||||
}
|
||||
+20
-14
@@ -297,20 +297,6 @@ mod tests {
|
||||
assert_eq!(ui_arabic_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Arab")).count());
|
||||
}
|
||||
|
||||
/// Family-name prefixes `build.rs` never embeds (its own `BARRED_FAMILIES`): Adobe's type
|
||||
/// designs, which includes Noto CJK (Source Han under another name).
|
||||
const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"];
|
||||
|
||||
/// Whatever the craft-fonts checkout holds, no Adobe type design is embedded (AGENTS.md §1.1).
|
||||
#[test]
|
||||
fn barred_families_are_never_embedded() {
|
||||
for face in CRAFT_FONTS {
|
||||
assert!(!BARRED_FAMILIES.iter().any(|barred| face.family.starts_with(barred)), "{} is embedded", face.name());
|
||||
}
|
||||
assert!(BARRED_FAMILIES.iter().any(|barred| "Noto Sans CJK SC".starts_with(barred)));
|
||||
assert!(!BARRED_FAMILIES.iter().any(|barred| "Noto Sans Arabic".starts_with(barred)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_document_faces_follow_the_weight() {
|
||||
static HEBR_LATN: &[&str] = &["Hebr", "Latn"];
|
||||
@@ -328,6 +314,26 @@ mod tests {
|
||||
assert_eq!(document_hebrew_font(false).is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Hebr")));
|
||||
}
|
||||
|
||||
/// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to
|
||||
/// `MAX_OTHER_CJK_BYTES`, so a checkout with a large Chinese or Korean face (craft-fonts has the
|
||||
/// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts
|
||||
/// are 24.1 MB.
|
||||
#[test]
|
||||
fn cjk_faces_follow_the_size_rule() {
|
||||
use crate::cjk_scripts::{MAX_OTHER_CJK_BYTES, WHOLE_CJK_FAMILIES, is_cjk_script};
|
||||
// A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`.
|
||||
assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s)));
|
||||
assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s)));
|
||||
let cjk = CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| is_cjk_script(s)));
|
||||
let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family));
|
||||
for face in &other {
|
||||
let len = face.bytes.len();
|
||||
assert!(u64::try_from(len).is_ok_and(|n| n <= MAX_OTHER_CJK_BYTES), "{} is embedded at {len} bytes", face.name());
|
||||
}
|
||||
let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum();
|
||||
assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces");
|
||||
}
|
||||
|
||||
/// The bundled Inter faces carry the Latin, Greek and Cyrillic letters the craft-fonts faces
|
||||
/// lack: Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ, Ukrainian ї є ґ, Greek. This one runs with or without
|
||||
/// CRAFT_FONTS_DIR (#2593).
|
||||
|
||||
@@ -156,8 +156,7 @@ impl Substitution {
|
||||
fn script_for(ordering: &str) -> Option<&'static str> {
|
||||
match ordering {
|
||||
"Japan1" => Some("Jpan"),
|
||||
// Registered orderings we have no openly licensed face for. AGENTS.md 1.1 rules out
|
||||
// Source Han and Noto CJK (Source Han rebranded), which is why these have none.
|
||||
// Registered orderings the chain maps to no craft-fonts face.
|
||||
"GB1" | "CNS1" | "Korea1" | "KR" => Some(""),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(test)]
|
||||
mod cjk_scripts;
|
||||
mod craft;
|
||||
pub mod embedding;
|
||||
mod encodings;
|
||||
|
||||
@@ -59,7 +59,7 @@ impl Std14 {
|
||||
/// report that called it a standard-14 name would be telling the reader something untrue.
|
||||
pub fn recognise(base: &str) -> Option<(Self, Match)> {
|
||||
let base = base.rsplit('+').next().unwrap_or(base);
|
||||
let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect();
|
||||
let flat: String = base.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect();
|
||||
let has = |w: &str| flat.contains(w);
|
||||
let (bold, italic) = (has("bold"), has("italic") || has("oblique"));
|
||||
// Two bools index a four-element array, so the subscript is provably 0..=3.
|
||||
|
||||
@@ -39,8 +39,9 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
|
||||
|
||||
- Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the
|
||||
field tree; the form's `/DA` and `/Q` are the last fallback.
|
||||
- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb,
|
||||
multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection.
|
||||
- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb
|
||||
(cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK`
|
||||
background and border, list-box selection.
|
||||
The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined
|
||||
Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold
|
||||
(Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat
|
||||
@@ -55,7 +56,9 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
|
||||
drawn from their values, as typing them would draw them; the engine shows and prints that copy
|
||||
and saves the file as it was (#3470). `with_field_appearances_cached` keeps each widget's
|
||||
drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in
|
||||
one field redraws that field, not the form.
|
||||
one field redraws that field, not the form. Check boxes and radio buttons with no appearance for
|
||||
their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats
|
||||
symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption.
|
||||
- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances
|
||||
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
|
||||
- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the
|
||||
|
||||
@@ -159,6 +159,60 @@ pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAc
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most chained actions [`field_action_chains`] walks per trigger.
|
||||
const MAX_CHAIN: usize = 1000;
|
||||
|
||||
/// How many actions follow `action` through `/Next` (a dictionary or an array of them, each with
|
||||
/// its own `/Next`), counting each object once.
|
||||
fn chained(doc: &Document, action: &Object) -> usize {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if let Object::Ref(r) = action {
|
||||
seen.insert(*r);
|
||||
}
|
||||
let mut stack: Vec<Object> = doc.resolve(action).as_dict().and_then(|d| d.get(b"Next").cloned()).into_iter().collect();
|
||||
let mut count = 0;
|
||||
while let Some(o) = stack.pop() {
|
||||
if count >= MAX_CHAIN {
|
||||
break;
|
||||
}
|
||||
if let Object::Ref(r) = &o
|
||||
&& !seen.insert(*r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match &*doc.resolve(&o) {
|
||||
Object::Array(items) => stack.extend(items.iter().cloned()),
|
||||
Object::Dict(d) => {
|
||||
count += 1;
|
||||
stack.extend(d.get(b"Next").cloned());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// For each trigger of field `name` (its first widget) whose action has others chained after it,
|
||||
/// how many there are.
|
||||
pub fn field_action_chains(doc: &Document, name: &str) -> Result<Vec<(Trigger, usize)>, FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let Some(w) = f.widgets.first() else { return Ok(Vec::new()) };
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
Ok(Trigger::ALL
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
let o = match t.key() {
|
||||
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
|
||||
Some(k) => aa.get(k).cloned(),
|
||||
}?;
|
||||
let n = chained(doc, &o);
|
||||
(n > 0).then_some((t, n))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Result<Option<Object>, FormError> {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Action"));
|
||||
@@ -207,11 +261,13 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) ->
|
||||
}
|
||||
|
||||
/// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose
|
||||
/// theirs. `Other` actions are left as they are.
|
||||
/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with
|
||||
/// their chain and the keys this module doesn't read); a replaced action keeps its chain.
|
||||
pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let pages = crate::page_refs(doc);
|
||||
let mut scripts = crate::script_budget();
|
||||
for w in &f.widgets {
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
@@ -221,10 +277,23 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
|
||||
if matches!(new, Some(FieldAction::Other(_))) {
|
||||
continue;
|
||||
}
|
||||
let old = match t.key() {
|
||||
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
|
||||
Some(k) => aa.get(k).cloned(),
|
||||
};
|
||||
let old_action = old.as_ref().and_then(|o| read(doc, o, &pages, &mut scripts));
|
||||
if new.is_some() && new == old_action.as_ref() {
|
||||
continue;
|
||||
}
|
||||
// The list shows a trigger's first action: replacing it keeps the ones chained after it.
|
||||
let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned()));
|
||||
let obj = match new {
|
||||
Some(a) => write(doc, a, &pages)?,
|
||||
None => None,
|
||||
};
|
||||
if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) {
|
||||
doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?;
|
||||
}
|
||||
match t.key() {
|
||||
None => {
|
||||
a_entry = obj;
|
||||
|
||||
+154
-29
@@ -379,10 +379,12 @@ pub fn parse_validate(js: &str) -> Option<Validate> {
|
||||
}
|
||||
|
||||
pub fn parse_calculate(js: &str) -> Option<Calculate> {
|
||||
// Simplified field notation: Acrobat keeps the expression in a comment.
|
||||
if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC")) {
|
||||
let expr = js.get(a + 6..b)?.trim().to_string();
|
||||
return Some(Calculate::Notation(expr));
|
||||
// Simplified field notation: Acrobat keeps the expression in a comment. A script is read for
|
||||
// its comments only when it spells the marker.
|
||||
if js.contains("BVCALC")
|
||||
&& let Some(range) = notation_range(js, &js_tokens(js).1)
|
||||
{
|
||||
return Some(Calculate::Notation(js.get(range)?.trim().to_string()));
|
||||
}
|
||||
let args = call(js, "AFSimple_Calculate")?;
|
||||
let op = match args.first().and_then(Arg::string)?.as_str() {
|
||||
@@ -479,21 +481,41 @@ enum Js {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A [`Js`] token with its byte range in the script.
|
||||
type Token = (Range<usize>, Js);
|
||||
|
||||
/// Words after which `/` starts a regular expression instead of dividing.
|
||||
const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"];
|
||||
|
||||
/// JavaScript's tokens with their byte ranges, comments left out: enough of the language to tell
|
||||
/// code from strings, comments and regular expressions. The tokens stop where a string, comment
|
||||
/// or template literal never ends.
|
||||
fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
|
||||
/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a
|
||||
/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so
|
||||
/// a `/` after it starts the next statement, and can't divide.
|
||||
const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"];
|
||||
|
||||
/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029.
|
||||
fn line_ends_at(s: &[u8], j: usize) -> bool {
|
||||
matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..]))
|
||||
}
|
||||
|
||||
/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment
|
||||
/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings,
|
||||
/// comments and regular expressions. The tokens stop where a string, comment or template literal
|
||||
/// never ends; a comment that never ends runs to the end of the script.
|
||||
fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
|
||||
let s = js.as_bytes();
|
||||
// Bytes past ASCII are parts of identifiers, so a range never ends inside a character.
|
||||
let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii();
|
||||
let mut out: Vec<(Range<usize>, Js)> = Vec::new();
|
||||
// Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except
|
||||
// U+2028 and U+2029, which end a line: a range never ends inside a character.
|
||||
let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j);
|
||||
let mut out: Vec<Token> = Vec::new();
|
||||
let mut comments = Vec::new();
|
||||
let mut i = 0;
|
||||
// Where a regular expression that never ended stopped being read: every `/` before it
|
||||
// divides, so a line of them is read once, not once for each.
|
||||
let mut divides_until = 0;
|
||||
// For each `(` still open, whether it opened the condition of `if`, `while`, `for` or
|
||||
// `with`; and whether the last token is the `)` that closed one.
|
||||
let mut conditions: Vec<bool> = Vec::new();
|
||||
let mut after_condition = false;
|
||||
'scan: while let Some(&c) = s.get(i) {
|
||||
let start = i;
|
||||
let token = match c {
|
||||
@@ -501,16 +523,27 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a
|
||||
// time, the rest of it would be read from inside the character.
|
||||
0xE2 if line_ends_at(s, i) => {
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
b'/' if s.get(i + 1) == Some(&b'/') => {
|
||||
i = s.iter().skip(i).position(|b| *b == b'\n').map_or(s.len(), |n| i + n);
|
||||
i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len());
|
||||
comments.push(start + 2..i);
|
||||
continue;
|
||||
}
|
||||
b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) {
|
||||
Some(n) => {
|
||||
comments.push(i + 2..i + 2 + n);
|
||||
i += n + 4;
|
||||
continue;
|
||||
}
|
||||
None => break 'scan,
|
||||
None => {
|
||||
comments.push(i + 2..s.len());
|
||||
break 'scan;
|
||||
}
|
||||
},
|
||||
b'"' | b'\'' => {
|
||||
let mut p = Parser { s, i, depth: 0 };
|
||||
@@ -531,13 +564,14 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
|
||||
i = j + 1;
|
||||
Js::Other
|
||||
}
|
||||
b'/' if i >= divides_until && regex_may_follow(js, &out) => {
|
||||
b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => {
|
||||
// A regular expression ends at a `/` outside a class; a line break first means it
|
||||
// was a division after all.
|
||||
let (mut j, mut class) = (i + 1, false);
|
||||
let end = loop {
|
||||
match s.get(j) {
|
||||
None | Some(b'\n') => break None,
|
||||
None => break None,
|
||||
Some(_) if line_ends_at(s, j) => break None,
|
||||
Some(b'\\') => j += 2,
|
||||
Some(b'/') if !class => break Some(j),
|
||||
Some(b) => {
|
||||
@@ -553,7 +587,7 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
|
||||
match end {
|
||||
Some(e) => {
|
||||
i = e + 1;
|
||||
while s.get(i).is_some_and(|b| word(*b)) {
|
||||
while word(i) {
|
||||
i += 1;
|
||||
}
|
||||
Js::Other
|
||||
@@ -566,13 +600,13 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
|
||||
}
|
||||
}
|
||||
_ if c.is_ascii_digit() => {
|
||||
while s.get(i).is_some_and(|b| word(*b) || *b == b'.') {
|
||||
while word(i) || s.get(i) == Some(&b'.') {
|
||||
i += 1;
|
||||
}
|
||||
Js::Other
|
||||
}
|
||||
_ if word(c) => {
|
||||
while s.get(i).is_some_and(|b| word(*b)) {
|
||||
_ if word(i) => {
|
||||
while word(i) {
|
||||
i += 1;
|
||||
}
|
||||
Js::Ident
|
||||
@@ -582,22 +616,52 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
|
||||
Js::Punct(char::from(c))
|
||||
}
|
||||
};
|
||||
after_condition = match token {
|
||||
Js::Punct('(') => {
|
||||
conditions.push(opens_condition(js, &out));
|
||||
false
|
||||
}
|
||||
Js::Punct(')') => conditions.pop().unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
out.push((start..i, token));
|
||||
}
|
||||
out
|
||||
(out, comments)
|
||||
}
|
||||
|
||||
/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening
|
||||
/// bracket or a keyword such as `return`, not after a value.
|
||||
fn regex_may_follow(js: &str, tokens: &[(Range<usize>, Js)]) -> bool {
|
||||
/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not
|
||||
/// after a value. `after_condition` says the last token is the `)` that ends such a condition.
|
||||
fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool {
|
||||
match tokens.last() {
|
||||
None => true,
|
||||
Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)),
|
||||
Some((_, Js::Punct(c))) => !matches!(c, ')' | ']' | '}'),
|
||||
Some((_, Js::Punct(')'))) => after_condition,
|
||||
Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'),
|
||||
Some((_, Js::Str(_) | Js::Other)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not
|
||||
/// the arguments of a method that has one of those names (`o.if(x)`).
|
||||
fn opens_condition(js: &str, tokens: &[Token]) -> bool {
|
||||
let mut back = tokens.iter().rev();
|
||||
let Some((range, Js::Ident)) = back.next() else { return false };
|
||||
js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.'))))
|
||||
}
|
||||
|
||||
/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the
|
||||
/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it
|
||||
/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation.
|
||||
fn notation_range(js: &str, comments: &[Range<usize>]) -> Option<Range<usize>> {
|
||||
comments.iter().find_map(|comment| {
|
||||
let text = js.get(comment.clone())?;
|
||||
let a = text.find("BVCALC")? + 6;
|
||||
let b = a + text.get(a..)?.find("EVCALC")?;
|
||||
Some(comment.start + a..comment.start + b)
|
||||
})
|
||||
}
|
||||
|
||||
/// A place where a Calculate script names a field.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Site {
|
||||
@@ -635,22 +699,22 @@ impl Site {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a Calculate script names fields, in order: the simplified field notation between
|
||||
/// `BVCALC` and `EVCALC` (where [`parse_calculate`] reads it), the field list of
|
||||
/// Where a Calculate script names fields, in order: the simplified field notation in its comment
|
||||
/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of
|
||||
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
|
||||
/// A name built at run time (`getField("row" + i)`) is not a site.
|
||||
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
|
||||
let mut sites = Vec::new();
|
||||
if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC"))
|
||||
&& let Some(expr) = js.get(a + 6..b)
|
||||
let (tokens, comments) = js_tokens(js);
|
||||
if let Some(at) = notation_range(js, &comments)
|
||||
&& let Some(expr) = js.get(at.clone())
|
||||
{
|
||||
for (range, token) in notation_tokens(expr) {
|
||||
if let Notation::Name(name) = token {
|
||||
sites.push((a + 6 + range.start..a + 6 + range.end, Site::Notation(name)));
|
||||
sites.push((at.start + range.start..at.start + range.end, Site::Notation(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let tokens = js_tokens(js);
|
||||
let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word));
|
||||
let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c));
|
||||
let string = |k: usize| match tokens.get(k) {
|
||||
@@ -1522,6 +1586,67 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The notation is read only from a comment, where Acrobat writes it: a string or code that
|
||||
/// spells the markers is left alone, by the Calculate tab and by a rename.
|
||||
#[test]
|
||||
fn notation_is_read_only_from_a_comment() {
|
||||
let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;";
|
||||
assert_eq!(parse_calculate(string), None);
|
||||
assert_eq!(rename_in_calculation(string, "a", "qty"), None);
|
||||
// A comment after such a string holds the notation.
|
||||
let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;";
|
||||
assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into())));
|
||||
assert_eq!(rename_in_calculation(both, "x", "y"), None);
|
||||
assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1")));
|
||||
// A line comment is a comment too, and an unfinished one still holds it.
|
||||
assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into())));
|
||||
assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into())));
|
||||
}
|
||||
|
||||
/// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line
|
||||
/// endings the code after one is still code; and a `/` read as starting a regular expression
|
||||
/// divides after all when the line ends first.
|
||||
#[test]
|
||||
fn a_line_comment_ends_at_any_line_terminator() {
|
||||
for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] {
|
||||
let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;");
|
||||
assert_eq!(parse_calculate(&string), None, "{end:?}");
|
||||
assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}");
|
||||
let script = format!("// note{end}getField(\"a\").value;");
|
||||
assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}");
|
||||
let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;");
|
||||
assert_eq!(rename_in_calculation(÷s, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes
|
||||
/// past ASCII they could be part of one, so a `getField` right after one is still a call.
|
||||
#[test]
|
||||
fn a_token_ends_at_a_line_separator() {
|
||||
for end in ["\u{2028}", "\u{2029}"] {
|
||||
let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;"));
|
||||
let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty"));
|
||||
assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression,
|
||||
/// as after an operator; after a call's parentheses it still divides.
|
||||
#[test]
|
||||
fn a_regex_after_a_condition_is_not_read_as_code() {
|
||||
let renamed = |js: &str| rename_in_calculation(js, "a", "qty");
|
||||
assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None);
|
||||
// A quote inside it doesn't start a string that hides the rest of the script.
|
||||
let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;";
|
||||
assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\"")));
|
||||
// The same after the `while` that ends a `do` loop.
|
||||
let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;";
|
||||
assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\"")));
|
||||
// After a call, or a method that happens to be called `if`, the `/` divides.
|
||||
let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;";
|
||||
assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\"")));
|
||||
}
|
||||
|
||||
/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in
|
||||
/// one another's field lists, don't make a rename read the rest of the script again for each.
|
||||
#[test]
|
||||
|
||||
+407
-14
@@ -72,6 +72,131 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i
|
||||
if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size }
|
||||
}
|
||||
|
||||
/// How a text field's widget lays its value out in [`field_appearance_drawn`], for asking
|
||||
/// whether a value fits it ([`TextBox::fits`]): what a field that doesn't scroll (`DoNotScroll`,
|
||||
/// ISO 32000-2 Table 228) still takes. Built from the same pieces the appearance uses: the `/DA`
|
||||
/// font from the form's `/DR` (Helvetica where that font can't show the text), the room inside
|
||||
/// the border and the 2 pt padding, the `/DA` size (or the automatic size), and wrapping for a
|
||||
/// multi-line field; the two must change together.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextBox {
|
||||
/// The `/DA` font from `/DR`, when it is one the appearance can draw with.
|
||||
font: Option<FontText>,
|
||||
/// Width and height inside the border and padding.
|
||||
inner_w: f64,
|
||||
inner_h: f64,
|
||||
/// The `/DA` size; `None` for an automatic (`0 Tf`) size, which shrinks to fit.
|
||||
size: Option<f64>,
|
||||
multiline: bool,
|
||||
password: bool,
|
||||
/// A comb field: its cells, not its width, bound it (`/MaxLen`).
|
||||
comb: bool,
|
||||
}
|
||||
|
||||
/// The [`TextBox`] widget `w` of text field `f` lays its value out in.
|
||||
pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox {
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
|
||||
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
|
||||
let pad = 2.0 + border_width(doc, &wd);
|
||||
let multiline = f.has(flags::MULTILINE);
|
||||
TextBox {
|
||||
font: dr_font(doc, &da.font).map(|(_, enc)| enc),
|
||||
inner_w: (place.layout_w - 2.0 * pad).max(1.0),
|
||||
inner_h: place.layout_h - 2.0 * pad,
|
||||
size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size),
|
||||
multiline,
|
||||
password: f.has(flags::PASSWORD),
|
||||
comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0),
|
||||
}
|
||||
}
|
||||
|
||||
impl TextBox {
|
||||
/// Whether `text` fits: on one line within the width, or, multi-line, wrapped within the
|
||||
/// height, as the appearance draws it. An automatic size shrinks before text stops fitting,
|
||||
/// so such a field is full only when the text no longer fits at its smallest size (4 pt). A
|
||||
/// comb field always fits (its `/MaxLen` bounds it).
|
||||
pub fn fits(&self, text: &str) -> bool {
|
||||
if self.comb || text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let shown: String = if self.password { "*".repeat(text.chars().count()) } else { text.to_string() };
|
||||
// The font the appearance would choose for this text.
|
||||
let can_show = |enc: &FontText| if self.multiline { shown.split(['\n', '\r']).all(|l| enc.can_encode(l)) } else { enc.can_encode(&shown) };
|
||||
let font = self.font.as_ref().filter(|enc| can_show(enc)).unwrap_or(&FontText::WinAnsi);
|
||||
let width_of = |t: &str, size: f64| font.width(t, size);
|
||||
// The smallest an automatic size goes ([`auto_text_size_by`]).
|
||||
let size = self.size.unwrap_or(4.0);
|
||||
if self.multiline {
|
||||
// As the appearance draws it: the first baseline is 0.85 em below the padded top and
|
||||
// a line is drawn while its descent (0.207 em) stays inside the border, so the
|
||||
// padding below the last line is not needed: `inner_h` plus 2 pt.
|
||||
let above = wrap_with(&shown, size, self.inner_w, width_of).len().saturating_sub(1) as f64;
|
||||
above * size * 1.15 + 1.057 * size <= self.inner_h + 2.0 + 0.01
|
||||
} else {
|
||||
// A line break in a single-line value is drawn as a space.
|
||||
let line: String = shown.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect();
|
||||
width_of(&line, size) <= self.inner_w + 1e-6
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit from `before` to `after` as a field that doesn't scroll takes it: all of it when
|
||||
/// `after` fits, and otherwise as much of the text it inserted (typed or pasted) as still
|
||||
/// fits, keeping what it removed removed. Returns the text and where the caret goes (in
|
||||
/// characters, after what was kept of the insertion).
|
||||
pub fn fitted_edit(&self, before: &str, after: &str) -> (String, usize) {
|
||||
let (b, a): (Vec<char>, Vec<char>) = (before.chars().collect(), after.chars().collect());
|
||||
// The edit replaced b[p..b.len() - q] with a[p..a.len() - q].
|
||||
let p = b.iter().zip(&a).take_while(|(x, y)| x == y).count();
|
||||
let most = b.len().min(a.len()).saturating_sub(p);
|
||||
let q = b.iter().rev().zip(a.iter().rev()).take(most).take_while(|(x, y)| x == y).count();
|
||||
// q <= a.len() - p, so these are in range; `get` keeps it so if that ever changes.
|
||||
let end = a.len().saturating_sub(q);
|
||||
let (prefix, inserted, suffix) = (a.get(..p).unwrap_or_default(), a.get(p..end).unwrap_or_default(), a.get(end..).unwrap_or_default());
|
||||
let with = |k: usize| prefix.iter().chain(inserted.get(..k).unwrap_or_default()).chain(suffix).collect::<String>();
|
||||
if self.fits(after) {
|
||||
return (after.to_string(), p + inserted.len());
|
||||
}
|
||||
// The longest of the insertion's first `from..=to` characters that fits, halving: in one
|
||||
// font a longer text is never narrower. `None` when even `from` doesn't fit.
|
||||
let longest = |from: usize, to: usize| -> Option<usize> {
|
||||
if from > to || !self.fits(&with(from)) {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = (from, to);
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 }
|
||||
}
|
||||
Some(lo)
|
||||
};
|
||||
// The font can change once, from the form's own to Helvetica, at the first inserted
|
||||
// character the form's font can't show: each side of that point is searched on its own.
|
||||
let n = inserted.len();
|
||||
let kept = match self.font_switch(prefix, inserted, suffix) {
|
||||
Some(t) => longest(0, t.saturating_sub(1)).into_iter().chain(longest(t, n)).max(),
|
||||
None => longest(0, n),
|
||||
}
|
||||
.unwrap_or(0);
|
||||
(with(kept), p + kept)
|
||||
}
|
||||
|
||||
/// Where an insertion changes the font [`Self::fits`] measures in: how much of `inserted`
|
||||
/// (between `prefix` and `suffix`) first includes a character the form's own font can't show,
|
||||
/// when everything around it can be shown in that font. `None` when the font never changes.
|
||||
fn font_switch(&self, prefix: &[char], inserted: &[char], suffix: &[char]) -> Option<usize> {
|
||||
let enc = self.font.as_ref().filter(|_| !self.password)?;
|
||||
// As `fits` decides: a multi-line value is checked line by line, its breaks aside.
|
||||
let shows = |c: &char| (self.multiline && matches!(c, '\n' | '\r')) || enc.can_encode(&c.to_string());
|
||||
if !prefix.iter().chain(suffix).all(shows) {
|
||||
return None;
|
||||
}
|
||||
inserted.iter().position(|c| !shows(c)).map(|i| i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90.
|
||||
pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 {
|
||||
let Some(raw) = wd.get(b"MK") else { return 0 };
|
||||
@@ -277,10 +402,14 @@ fn stroke_op(fill: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background and border from `/MK` and `/BS`; returns (content, border width).
|
||||
/// Background and border from `/MK` and `/BS`; returns (content, border width). A round XFA
|
||||
/// check button's ([`is_round`]) are a circle inside the widget instead of its rectangle.
|
||||
fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
|
||||
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
|
||||
let mut c = String::new();
|
||||
if is_round(doc, wd) {
|
||||
return round_frame(doc, wd, mk.as_ref(), w, h);
|
||||
}
|
||||
if let Some(bg) = color_array(doc, mk.as_ref(), b"BG") {
|
||||
c.push_str(&format!("{bg}\n0 0 {} {} re f\n", n(w), n(h)));
|
||||
}
|
||||
@@ -346,12 +475,89 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
|
||||
(c, 0.0)
|
||||
}
|
||||
|
||||
/// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button
|
||||
/// (`/PCXfaRound true`, written by the XFA layout).
|
||||
fn is_round(doc: &Document, wd: &Dict) -> bool {
|
||||
matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))
|
||||
}
|
||||
|
||||
/// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC`
|
||||
/// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A
|
||||
/// dashed border stays dashed and an underline stays an underline; any other style is drawn
|
||||
/// solid, and a border as wide as the circle fills it. Returns the border width.
|
||||
fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) {
|
||||
let mut c = String::new();
|
||||
let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0);
|
||||
if let Some(bg) = color_array(doc, mk, b"BG") {
|
||||
c.push_str(&format!("{bg}\n{}f\n", circle_path(cx, cy, r)));
|
||||
}
|
||||
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
|
||||
let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0);
|
||||
let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default();
|
||||
match color_array(doc, mk, b"BC") {
|
||||
// An underline stays one: only the bottom edge, as for any widget.
|
||||
Some(bc) if bw > 0.0 && style == b"U" => {
|
||||
c.push_str(&format!("{}\n{} w\n0 {} m {} {} l S\n", stroke_op(&bc), n(bw), n(bw / 2.0), n(w), n(bw / 2.0)));
|
||||
(c, bw)
|
||||
}
|
||||
// A border as wide as the circle fills it: a disc in the border's colour.
|
||||
Some(bc) if bw > 0.0 && bw >= 2.0 * r => {
|
||||
c.push_str(&format!("{bc}\n{}f\n", circle_path(cx, cy, r)));
|
||||
(c, 2.0 * r)
|
||||
}
|
||||
Some(bc) if bw > 0.0 => {
|
||||
let dashed = style == b"D";
|
||||
// The stroke is centred on the path: inset by half its width, it stays inside.
|
||||
let path = circle_path(cx, cy, r - bw / 2.0);
|
||||
c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" }));
|
||||
(c, bw)
|
||||
}
|
||||
_ => (c, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed circle of radius `r` around (`cx`, `cy`) as four Bézier arcs, ending with a space
|
||||
/// so a paint operator can follow.
|
||||
fn circle_path(cx: f64, cy: f64, r: f64) -> String {
|
||||
// The control-point distance for a quarter circle.
|
||||
let k = 0.5523 * r;
|
||||
format!(
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c ",
|
||||
n(cx + r),
|
||||
n(cy),
|
||||
n(cx + r),
|
||||
n(cy + k),
|
||||
n(cx + k),
|
||||
n(cy + r),
|
||||
n(cx),
|
||||
n(cy + r),
|
||||
n(cx - k),
|
||||
n(cy + r),
|
||||
n(cx - r),
|
||||
n(cy + k),
|
||||
n(cx - r),
|
||||
n(cy),
|
||||
n(cx - r),
|
||||
n(cy - k),
|
||||
n(cx - k),
|
||||
n(cy - r),
|
||||
n(cx),
|
||||
n(cy - r),
|
||||
n(cx + k),
|
||||
n(cy - r),
|
||||
n(cx + r),
|
||||
n(cy - k),
|
||||
n(cx + r),
|
||||
n(cy)
|
||||
)
|
||||
}
|
||||
|
||||
/// The width [`frame`] insets the content by: the `/BS` border width when the widget has a
|
||||
/// border colour, doubled for beveled and inset borders, and 0 without a border.
|
||||
fn border_width(doc: &Document, wd: &Dict) -> f64 {
|
||||
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
|
||||
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
|
||||
let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0);
|
||||
let bw = line_width(doc, wd);
|
||||
if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -359,6 +565,37 @@ fn border_width(doc: &Document, wd: &Dict) -> f64 {
|
||||
if style == b"B" || style == b"I" { 2.0 * bw } else { bw }
|
||||
}
|
||||
|
||||
/// The widget's border line width, `/BS /W` (default 1, Table 168), as [`frame`] strokes it.
|
||||
fn line_width(doc: &Document, wd: &Dict) -> f64 {
|
||||
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
|
||||
// `f64::max` turns NaN into 0, as it does a negative width.
|
||||
bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0)
|
||||
}
|
||||
|
||||
/// Comb fields stop drawing their cells past this many; they would be a solid block anyway.
|
||||
const MAX_COMB_DIVIDERS: usize = 500;
|
||||
|
||||
/// The lines between the `cells` positions of a comb field `w` × `h` (ISO 32000-2 Table 228,
|
||||
/// Comb: the field is divided into `MaxLen` equally spaced positions), in the border's colour and
|
||||
/// width, inside the border. None without a border, or past [`MAX_COMB_DIVIDERS`] cells.
|
||||
fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> String {
|
||||
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
|
||||
let inset = border_width(doc, wd);
|
||||
let lw = line_width(doc, wd);
|
||||
let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() };
|
||||
if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) {
|
||||
return String::new();
|
||||
}
|
||||
let cell = w / cells as f64;
|
||||
let mut c = format!("{}\n{} w\n", stroke_op(&bc), n(lw));
|
||||
for i in 1..cells {
|
||||
let x = cell * i as f64;
|
||||
c.push_str(&format!("{} {} m {} {} l\n", n(x), n(inset), n(x), n(h - inset)));
|
||||
}
|
||||
c.push_str("S\n");
|
||||
c
|
||||
}
|
||||
|
||||
/// Where a list box draws its options: one row per option from the top, starting with option
|
||||
/// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share
|
||||
/// it, so a click picks the option drawn under the pointer (#1524).
|
||||
@@ -449,6 +686,45 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
|
||||
/// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't
|
||||
/// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF.
|
||||
pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>) {
|
||||
let (stream, unencodable, _) = field_appearance_fit(doc, f, w, values, format);
|
||||
(stream, unencodable)
|
||||
}
|
||||
|
||||
/// A text value that its box cuts off at the field's font size (#4018).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Overflow {
|
||||
/// One line `width` points wide, with `room` for it, at `size` points.
|
||||
Wide { width: f64, room: f64, size: f64 },
|
||||
/// `lines` wrapped lines, of which the box shows `room`, at `size` points.
|
||||
Tall { lines: usize, room: usize, size: f64 },
|
||||
/// One line `height` points high (ascent to descent), with `room` for it, at `size` points.
|
||||
High { height: f64, room: f64, size: f64 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Overflow {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Wide { width, room, size } => {
|
||||
write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room))
|
||||
}
|
||||
Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)),
|
||||
Self::High { height, room, size } => {
|
||||
write!(f, "the value does not fit: {} pt high at {} pt, room for {} pt", fmt(*height), fmt(*size), fmt(*room))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the text field's value, drawn in widget `w`, is cut off by its box.
|
||||
pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option<Overflow> {
|
||||
if f.kind != FieldKind::Text {
|
||||
return None;
|
||||
}
|
||||
field_appearance_fit(doc, f, w, &f.value, true).2
|
||||
}
|
||||
|
||||
/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box.
|
||||
fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>, Option<Overflow>) {
|
||||
// The Format event: what is shown, not what is stored.
|
||||
let formatted: Vec<String>;
|
||||
let values = if format
|
||||
@@ -513,6 +789,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
|
||||
}
|
||||
};
|
||||
let mut size = da.size;
|
||||
let mut overflow = None;
|
||||
match f.kind {
|
||||
FieldKind::List => {
|
||||
// Every option, one per line from the top (from `/TI`); selected ones highlighted.
|
||||
@@ -539,13 +816,25 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
|
||||
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
|
||||
}
|
||||
let mut y = height - pad - size * 0.85;
|
||||
for line in wrap_with(&text, size, inner_w, width_of) {
|
||||
let lines = wrap_with(&text, size, inner_w, width_of);
|
||||
// The lines whose descent stays inside the border; the rest are cut off.
|
||||
let mut room = 0;
|
||||
for line in &lines {
|
||||
if y < -size {
|
||||
break;
|
||||
}
|
||||
show(&mut body, x_for(&line, size), y, &line);
|
||||
if y - 0.207 * size >= bw - 0.01 {
|
||||
room += 1;
|
||||
}
|
||||
show(&mut body, x_for(line, size), y, line);
|
||||
y -= size * 1.15;
|
||||
}
|
||||
if room < lines.len() {
|
||||
overflow = Some(Overflow::Tall { lines: lines.len(), room, size });
|
||||
} else if let Some(tw) = lines.iter().map(|l| width_of(l, size)).filter(|tw| *tw > inner_w + 0.01).reduce(f64::max) {
|
||||
// A character wider than the box is a line of its own, still cut at the sides.
|
||||
overflow = Some(Overflow::Wide { width: tw, room: inner_w, size });
|
||||
}
|
||||
} else {
|
||||
if size == 0.0 {
|
||||
// Comb cells hold one character each, so only the height limits them.
|
||||
@@ -557,14 +846,24 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
|
||||
}
|
||||
// Centre Helvetica's ascent (0.718) and descent (0.207) vertically.
|
||||
let y = (height - 0.925 * size) / 2.0 + 0.207 * size;
|
||||
// The clip box is inside the border; a line taller than it is cut at the top and bottom.
|
||||
let room_h = height - 2.0 * bw;
|
||||
if 0.925 * size > room_h + 0.01 {
|
||||
overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size });
|
||||
}
|
||||
if comb {
|
||||
let cells = f.max_len.unwrap_or(1).max(1);
|
||||
let cell = width / cells as f64;
|
||||
c.push_str(&comb_dividers(doc, &wd, width, height, cells));
|
||||
for (i, ch) in text.chars().take(cells).enumerate() {
|
||||
let s = ch.to_string();
|
||||
show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s);
|
||||
}
|
||||
} else {
|
||||
let tw = width_of(&text.replace(['\n', '\r'], " "), size);
|
||||
if overflow.is_none() && tw > inner_w + 0.01 {
|
||||
overflow = Some(Overflow::Wide { width: tw, room: inner_w, size });
|
||||
}
|
||||
show(&mut body, x_for(&text, size), y, &text);
|
||||
}
|
||||
}
|
||||
@@ -595,13 +894,17 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
set_form_box(&mut d, place);
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
(Stream::flate(d, &content), unencodable)
|
||||
// Nothing drawn is nothing cut, however small the box.
|
||||
let overflow = overflow.filter(|_| !text.is_empty());
|
||||
(Stream::flate(d, &content), unencodable, overflow)
|
||||
}
|
||||
|
||||
/// On/Off appearances for a check box or radio button that has none (a check mark or a dot,
|
||||
/// drawn as paths, so no symbol font is needed).
|
||||
/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the
|
||||
/// Appearance tab's Text Color (#4261).
|
||||
/// The streams are added to `doc` as indirect objects (streams can't be direct objects).
|
||||
pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict {
|
||||
pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str, f_da: &str) -> Dict {
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
@@ -609,6 +912,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
let (frame_c, _) = frame(doc, &wd, width, height);
|
||||
let style = crate::author::CheckStyle::of_widget(doc, &wd, kind);
|
||||
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da));
|
||||
let mut form = |content: String| -> Object {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
@@ -617,7 +921,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name
|
||||
Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes()))))
|
||||
};
|
||||
let s = width.min(height);
|
||||
let mark = check_mark(style, width / 2.0, height / 2.0, s);
|
||||
let mark = check_mark(style, width / 2.0, height / 2.0, s, &da.color);
|
||||
let mut nd = Dict::new();
|
||||
nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}")));
|
||||
nd.set(b"Off".to_vec(), form(frame_c));
|
||||
@@ -627,12 +931,13 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name
|
||||
}
|
||||
|
||||
/// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is
|
||||
/// `s`, drawn in black.
|
||||
fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String {
|
||||
/// `s`, drawn in the fill colour operation `color` (`0 g`, `0 0 1 rg`).
|
||||
fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: &str) -> String {
|
||||
use crate::author::CheckStyle;
|
||||
let (fill, stroke) = (color, stroke_op(color));
|
||||
// A filled polygon through `pts`.
|
||||
let polygon = |pts: &[(f64, f64)]| {
|
||||
let mut c = String::from("0 g\n");
|
||||
let mut c = format!("{fill}\n");
|
||||
for (i, (x, y)) in pts.iter().enumerate() {
|
||||
c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" }));
|
||||
}
|
||||
@@ -642,7 +947,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str
|
||||
let line = n((s * 0.1).max(1.0));
|
||||
match style {
|
||||
CheckStyle::Check => format!(
|
||||
"0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
|
||||
"{stroke}\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
|
||||
n(cx - s * 0.28),
|
||||
n(cy),
|
||||
n(cx - s * 0.08),
|
||||
@@ -653,7 +958,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str
|
||||
CheckStyle::Cross => {
|
||||
let d = s * 0.25;
|
||||
format!(
|
||||
"0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n",
|
||||
"{stroke}\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n",
|
||||
n(cx - d),
|
||||
n(cy - d),
|
||||
n(cx + d),
|
||||
@@ -666,7 +971,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str
|
||||
}
|
||||
CheckStyle::Square => {
|
||||
let d = s * 0.22;
|
||||
format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d))
|
||||
format!("{fill}\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d))
|
||||
}
|
||||
CheckStyle::Diamond => {
|
||||
let d = s * 0.3;
|
||||
@@ -688,7 +993,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str
|
||||
let r = s * 0.25;
|
||||
let k = 0.5523 * r;
|
||||
format!(
|
||||
"0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n",
|
||||
"{fill}\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n",
|
||||
n(cx + r),
|
||||
n(cy),
|
||||
n(cx + r),
|
||||
@@ -720,6 +1025,94 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str
|
||||
}
|
||||
}
|
||||
|
||||
/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when
|
||||
/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and
|
||||
/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes
|
||||
/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of
|
||||
/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is
|
||||
/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most
|
||||
/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed.
|
||||
pub(crate) fn button_state_shown(doc: &Document, f: &Field, w: &Widget, on: bool) -> Stream {
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
let (frame_c, bw) = frame(doc, &wd, width, height);
|
||||
let mut content = frame_c.into_bytes();
|
||||
let mut res = Dict::new();
|
||||
if on {
|
||||
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
|
||||
let caption = mk.as_ref().and_then(|mk| mk.get(b"CA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text()));
|
||||
let dingbats = pdfcraft_fonts::Std14::ZapfDingbats;
|
||||
// The caption's first character as a ZapfDingbats code, when the font has a glyph there.
|
||||
let code = caption
|
||||
.and_then(|s| s.chars().next())
|
||||
.and_then(|ch| u8::try_from(u32::from(ch)).ok())
|
||||
.filter(|&code| dingbats.width(code) > 0.0)
|
||||
.unwrap_or(if f.kind == FieldKind::Radio { b'l' } else { b'4' });
|
||||
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
|
||||
// The glyph's advance and (about) its height, in ems.
|
||||
let (advance, tall) = (dingbats.width(code) / 1000.0, 0.75);
|
||||
let room = |side: f64| (side - 2.0 * bw - 2.0).max(1.0);
|
||||
// An automatic size leaves a margin of a tenth of the room on each side.
|
||||
let size = if da.size > 0.0 { da.size } else { (0.8 * room(width) / advance.max(0.1)).min(0.8 * room(height) / tall).clamp(1.0, 300.0) };
|
||||
let (x, y) = ((width - advance * size) / 2.0, (height - tall * size) / 2.0 + 0.05 * size);
|
||||
content.extend(format!("q\nBT\n/ZaDb {} Tf\n{}\n{} {} Td\n", n(size), da.color, n(x), n(y)).bytes());
|
||||
content.extend(literal(&[code]));
|
||||
content.extend_from_slice(b" Tj\nET\nQ\n");
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"ZaDb".to_vec(), Object::Dict(font));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
set_form_box(&mut d, place);
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
Stream::flate(d, &content)
|
||||
}
|
||||
|
||||
/// How a push button widget `w` of field `f` with no normal appearance looks: its `/MK`
|
||||
/// background and border, in the `/BS` width and style ([`frame`]: Table 168, `B` beveled, `I`
|
||||
/// inset), with its caption `/MK /CA` (Table 192) centred in the `/DA` font, size and colour.
|
||||
/// A button with an icon (`/MK /I`) is drawn as PdfCraft draws its own buttons, icon included.
|
||||
pub(crate) fn push_button_shown(doc: &Document, f: &Field, w: &Widget) -> Stream {
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
|
||||
if mk.contains(b"I") {
|
||||
return crate::author::button_appearance(doc, w, &f.da).0;
|
||||
}
|
||||
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
let (frame_c, _) = frame(doc, &wd, width, height);
|
||||
let mut content = frame_c.into_bytes();
|
||||
let caption = mk.get(b"CA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_default();
|
||||
let mut fonts = Dict::new();
|
||||
if !caption.is_empty() && mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) != Some(1) {
|
||||
let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text()));
|
||||
// The automatic size PdfCraft's own buttons use.
|
||||
let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0);
|
||||
let (ops, name, font, _) = button_caption(doc, da.as_deref().unwrap_or(&f.da), &caption, width, height, auto);
|
||||
content.extend(ops);
|
||||
fonts.set(name.into_bytes(), font);
|
||||
}
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
set_form_box(&mut d, place);
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
Stream::flate(d, &content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+380
-68
@@ -134,10 +134,9 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result<ObjRef, FormError> {
|
||||
if !af.contains(b"DA") {
|
||||
af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec()));
|
||||
}
|
||||
let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let (dr, mut fonts) = form_fonts(doc, &af);
|
||||
let mut added = inline_fonts_as_objects(doc, &mut fonts);
|
||||
// Acrobat's resource names for the standard fonts its Appearance tab offers.
|
||||
let mut added = false;
|
||||
for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] {
|
||||
if fonts.contains(name.as_bytes()) {
|
||||
continue;
|
||||
@@ -147,17 +146,105 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result<ObjRef, FormError> {
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
fonts.set(name.as_bytes().to_vec(), Object::Dict(f));
|
||||
let r = doc.add(Object::Dict(f));
|
||||
fonts.set(name.as_bytes().to_vec(), Object::Ref(r));
|
||||
added = true;
|
||||
}
|
||||
if added {
|
||||
dr.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
af.set(b"DR".to_vec(), Object::Dict(dr));
|
||||
store_form_fonts(doc, &mut af, dr, fonts);
|
||||
}
|
||||
doc.set(r, Object::Dict(af));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing).
|
||||
fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) {
|
||||
let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
(dr, fonts)
|
||||
}
|
||||
|
||||
/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows
|
||||
/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline
|
||||
/// (by an earlier save, or another writer) become objects saying the same; a font that is an
|
||||
/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed.
|
||||
fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool {
|
||||
let inline: Vec<(Vec<u8>, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect();
|
||||
let changed = !inline.is_empty();
|
||||
for (name, font) in inline {
|
||||
let r = doc.add(Object::Dict(font));
|
||||
fonts.set(name, Object::Ref(r));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or
|
||||
/// `/Font` stays the object it is.
|
||||
fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) {
|
||||
match dr.get(b"Font").and_then(Object::as_ref) {
|
||||
Some(fr) => doc.set(fr, Object::Dict(fonts)),
|
||||
None => dr.set(b"Font".to_vec(), Object::Dict(fonts)),
|
||||
}
|
||||
match af.get(b"DR").and_then(Object::as_ref) {
|
||||
Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)),
|
||||
None => af.set(b"DR".to_vec(), Object::Dict(dr)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Filling a field: the form's inline `/DR` fonts become objects (see
|
||||
/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat
|
||||
/// once it has been filled here too (#3441). Only the font storage changes; nothing is added.
|
||||
/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the
|
||||
/// value itself.
|
||||
pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> {
|
||||
let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj));
|
||||
if signed {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(root) = doc.root() else { return Ok(()) };
|
||||
let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) };
|
||||
match entry {
|
||||
Object::Ref(r) => {
|
||||
let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) };
|
||||
let (dr, mut fonts) = form_fonts(doc, &af);
|
||||
if inline_fonts_as_objects(doc, &mut fonts) {
|
||||
store_form_fonts(doc, &mut af, dr, fonts);
|
||||
doc.set(r, Object::Dict(af));
|
||||
}
|
||||
}
|
||||
// A form dictionary written directly in the catalog (the catalog's /AcroForm need not be
|
||||
// an indirect reference) is written back there.
|
||||
Object::Dict(mut af) => {
|
||||
let (dr, mut fonts) = form_fonts(doc, &af);
|
||||
if inline_fonts_as_objects(doc, &mut fonts) {
|
||||
store_form_fonts(doc, &mut af, dr, fonts);
|
||||
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is
|
||||
/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep
|
||||
/// as the field walk goes, so a cyclic chain ends.
|
||||
fn holds_value(doc: &Document, r: ObjRef) -> bool {
|
||||
let mut at = r;
|
||||
for _ in 0..=64 {
|
||||
let obj = doc.get(at);
|
||||
let Some(d) = obj.as_dict() else { return false };
|
||||
if d.contains(b"V") {
|
||||
return true;
|
||||
}
|
||||
match d.reference(b"Parent") {
|
||||
Some(p) => at = p,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The Appearance tab: borders, colours and text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Look {
|
||||
@@ -486,14 +573,228 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first free "<base>N" name.
|
||||
fn free_name(existing: &[Field], base: &str) -> String {
|
||||
/// The most parts a dotted field name may have ("a.b.c" has three).
|
||||
const MAX_NAME_PARTS: usize = 32;
|
||||
|
||||
/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1).
|
||||
const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"];
|
||||
|
||||
/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a
|
||||
/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696).
|
||||
fn name_parts(name: &str) -> Result<Vec<&str>, FormError> {
|
||||
let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect();
|
||||
if parts.len() > MAX_NAME_PARTS {
|
||||
return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts"));
|
||||
}
|
||||
if parts.iter().any(|p| p.trim().is_empty()) {
|
||||
return invalid("field names can't be empty, or have an empty part between periods");
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
/// Why the full name `name` can't be used beside the field named `other`, if it can't: the
|
||||
/// same name, or one inside the other (a field that has a value can't also hold fields).
|
||||
fn clash(name: &str, other: &str) -> Option<String> {
|
||||
if name == other {
|
||||
return Some(format!("a field named {name:?} already exists"));
|
||||
}
|
||||
let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.'));
|
||||
(inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}"))
|
||||
}
|
||||
|
||||
/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field).
|
||||
fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
|
||||
let d = doc.get(holder).as_dict().cloned().unwrap_or_default();
|
||||
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top.
|
||||
fn link(doc: &mut Document, af: ObjRef, parent: Option<ObjRef>, field: ObjRef) -> Result<(), FormError> {
|
||||
let Some(p) = parent else { return add_to_fields(doc, af, field) };
|
||||
let mut kids = list_of(doc, p, b"Kids");
|
||||
kids.push(Object::Ref(field));
|
||||
doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form.
|
||||
fn child_named(doc: &Document, af: ObjRef, parent: Option<ObjRef>, name: &str) -> Option<ObjRef> {
|
||||
let list = match parent {
|
||||
Some(p) => list_of(doc, p, b"Kids"),
|
||||
None => list_of(doc, af, b"Fields"),
|
||||
};
|
||||
list.iter()
|
||||
.filter_map(|o| o.as_ref())
|
||||
.find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name))
|
||||
}
|
||||
|
||||
/// The values a parent field passes to the fields under it that can't be turned off by giving
|
||||
/// the field a neutral one of its own, as `/Ff` and `/Q` can.
|
||||
const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"];
|
||||
|
||||
/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The
|
||||
/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node
|
||||
/// of the same name there is found here.
|
||||
/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]).
|
||||
fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> {
|
||||
let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") };
|
||||
let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default();
|
||||
for (k, v) in inherited.iter() {
|
||||
owned.set(k.to_vec(), v.clone());
|
||||
}
|
||||
let mut at: Option<ObjRef> = None;
|
||||
let mut whole_path = true;
|
||||
for part in parents {
|
||||
let Some(r) = child_named(doc, af, at, part) else {
|
||||
whole_path = false;
|
||||
break;
|
||||
};
|
||||
// A value, reset value or length limit held by a parent would pass to this field.
|
||||
let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k)));
|
||||
if held {
|
||||
return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join(".")));
|
||||
}
|
||||
at = Some(r);
|
||||
}
|
||||
// A field being moved is still in the tree here, but under another full name (a move to its
|
||||
// own name changes nothing and doesn't get here), so a node found is another.
|
||||
if whole_path && child_named(doc, af, at, own).is_some() {
|
||||
return invalid(format!("a field named {:?} already exists", parts.join(".")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part,
|
||||
/// and the parts before it name parent fields, reused where the tree has them and made where
|
||||
/// not (Acrobat makes `a.b` a field `b` under a field `a`).
|
||||
fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> {
|
||||
let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") };
|
||||
check_place(doc, af, field, parts, &Dict::new())?;
|
||||
let mut parent: Option<ObjRef> = None;
|
||||
let mut inherits = false;
|
||||
for part in parents {
|
||||
let next = match child_named(doc, af, parent, part) {
|
||||
Some(r) => {
|
||||
inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q"));
|
||||
r
|
||||
}
|
||||
None => {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"T".to_vec(), PdfString::text(part));
|
||||
d.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
if let Some(p) = parent {
|
||||
d.set(b"Parent".to_vec(), Object::Ref(p));
|
||||
}
|
||||
let r = doc.add(Object::Dict(d));
|
||||
link(doc, af, parent, r)?;
|
||||
r
|
||||
}
|
||||
};
|
||||
parent = Some(next);
|
||||
}
|
||||
doc.update_dict(field, |d| {
|
||||
d.set(b"T".to_vec(), PdfString::text(own));
|
||||
match parent {
|
||||
Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)),
|
||||
None => {
|
||||
d.remove(b"Parent");
|
||||
}
|
||||
}
|
||||
// An existing parent's flags and alignment would otherwise pass to this field.
|
||||
if inherits {
|
||||
for k in [&b"Ff"[..], b"Q"] {
|
||||
if !d.contains(k) {
|
||||
d.set(k.to_vec(), Object::Int(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
link(doc, af, parent, field)
|
||||
}
|
||||
|
||||
/// What `field` inherits from its parents and doesn't hold itself, changing nothing.
|
||||
fn inherited(doc: &Document, field: ObjRef) -> Dict {
|
||||
let own = doc.get(field).as_dict().cloned().unwrap_or_default();
|
||||
let mut add = Dict::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut parent = own.reference(b"Parent");
|
||||
while let Some(p) = parent {
|
||||
if seen.len() > 64 || !seen.insert(p) {
|
||||
break;
|
||||
}
|
||||
let Some(d) = doc.get(p).as_dict().cloned() else { break };
|
||||
for k in INHERITED_KEYS {
|
||||
if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) {
|
||||
add.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
parent = d.reference(b"Parent");
|
||||
}
|
||||
add
|
||||
}
|
||||
|
||||
/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves.
|
||||
fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> {
|
||||
if add.iter().next().is_some() {
|
||||
doc.update_dict(field, |d| {
|
||||
for (k, v) in add.iter() {
|
||||
d.set(k.to_vec(), v.clone());
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`.
|
||||
fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> {
|
||||
let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent"));
|
||||
let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> {
|
||||
let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default();
|
||||
let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
let kept: Vec<Object> = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect();
|
||||
d.set(key.to_vec(), Object::Array(kept));
|
||||
doc.set(holder, Object::Dict(d));
|
||||
Ok(())
|
||||
};
|
||||
match parent {
|
||||
Some(p) => remove_from(doc, p, b"Kids")?,
|
||||
None => {
|
||||
let root = doc.root().ok_or(FormError::NoForm)?;
|
||||
match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) {
|
||||
Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?,
|
||||
// A form dictionary held directly in the catalog.
|
||||
Some(Object::Dict(mut af)) => {
|
||||
let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
let kept: Vec<Object> = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect();
|
||||
af.set(b"Fields".to_vec(), Object::Array(kept));
|
||||
doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first free "<base>N" name: not a field's, and not held by a node at the top of the field
|
||||
/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`.
|
||||
fn free_name(existing: &[Field], held: &[String], base: &str) -> String {
|
||||
(1..=u64::MAX)
|
||||
.map(|n| format!("{base}{n}"))
|
||||
.find(|c| !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}."))))
|
||||
.find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}."))))
|
||||
.unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing.
|
||||
fn top_names(doc: &Document) -> Vec<String> {
|
||||
let Some(root) = doc.root() else { return Vec::new() };
|
||||
let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect()
|
||||
}
|
||||
|
||||
fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
@@ -527,20 +828,18 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
_ => name,
|
||||
};
|
||||
let name = match name.map(str::trim) {
|
||||
Some(n) if !n.is_empty() => {
|
||||
if n.contains('.') {
|
||||
return invalid("field names can't contain a period");
|
||||
}
|
||||
n.to_string()
|
||||
}
|
||||
_ => free_name(&existing, kind.base_name()),
|
||||
Some(n) if !n.is_empty() => n.to_string(),
|
||||
_ => free_name(&existing, &top_names(doc), kind.base_name()),
|
||||
};
|
||||
let parts = name_parts(&name)?;
|
||||
let radio_group = match kind {
|
||||
NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
if radio_group.is_none() && existing.iter().any(|f| f.name == name) {
|
||||
return invalid(format!("a field named {name:?} already exists"));
|
||||
if radio_group.is_none()
|
||||
&& let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name))
|
||||
{
|
||||
return invalid(why);
|
||||
}
|
||||
let af = ensure_form(doc)?;
|
||||
let mut w = widget_dict(page_ref, rect);
|
||||
@@ -637,12 +936,11 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
let mut g = Dict::new();
|
||||
g.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64));
|
||||
g.set(b"T".to_vec(), PdfString::text(&field_name));
|
||||
g.set(b"V".to_vec(), Object::name("Off"));
|
||||
g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec()));
|
||||
g.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
let g = doc.add(Object::Dict(g));
|
||||
add_to_fields(doc, af, g)?;
|
||||
attach(doc, af, g, &parts)?;
|
||||
g
|
||||
}
|
||||
};
|
||||
@@ -666,13 +964,10 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
caption: None,
|
||||
list: None,
|
||||
};
|
||||
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
|
||||
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g");
|
||||
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
|
||||
}
|
||||
_ => {
|
||||
doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?;
|
||||
add_to_fields(doc, af, widget)?;
|
||||
}
|
||||
_ => attach(doc, af, widget, &parts)?,
|
||||
}
|
||||
add_to_page(doc, page_ref, widget)?;
|
||||
redraw_field(doc, &field_name)?;
|
||||
@@ -709,7 +1004,7 @@ fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result
|
||||
}
|
||||
FieldKind::CheckBox | FieldKind::Radio => {
|
||||
let on = w.on_state.clone().unwrap_or_else(|| "Yes".into());
|
||||
appearance::check_box_states(doc, w, f.kind, &on)
|
||||
appearance::check_box_states(doc, w, f.kind, &on, &f.da)
|
||||
}
|
||||
FieldKind::PushButton => {
|
||||
let (s, m) = button_appearance(doc, w, &f.da);
|
||||
@@ -775,7 +1070,7 @@ fn form_stream(place: crate::appearance::Placement, content: Vec<u8>, resources:
|
||||
/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one).
|
||||
/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to
|
||||
/// mark and report.
|
||||
fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option<char>) {
|
||||
pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option<char>) {
|
||||
let (mut c, place) = frame_only(doc, w);
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
let wobj = doc.get(w.obj);
|
||||
@@ -919,18 +1214,52 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
let mut new_name = name.to_string();
|
||||
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
|
||||
if n.is_empty() || n.contains('.') {
|
||||
return invalid("field names can't be empty or contain a period");
|
||||
// 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)))?;
|
||||
}
|
||||
// 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 all.iter().any(|x| x.obj != f.obj && x.name == new_name) {
|
||||
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)?;
|
||||
@@ -980,7 +1309,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
{
|
||||
return invalid("alignment is 0 (left), 1 (centre) or 2 (right)");
|
||||
}
|
||||
if props.options.as_ref().is_some_and(|o| o.is_empty()) && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
|
||||
if props.options.as_ref().is_some_and(Vec::is_empty) && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
|
||||
return invalid("a list needs at least one option");
|
||||
}
|
||||
if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
|
||||
@@ -1239,8 +1568,17 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st
|
||||
|
||||
/// Delete a field: its widgets leave their pages and the field leaves the form.
|
||||
pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
delete_found_field(doc, f)
|
||||
}
|
||||
|
||||
/// [`delete_field`] for the field object `obj`, when separate fields share one name.
|
||||
pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?;
|
||||
delete_found_field(doc, f)
|
||||
}
|
||||
|
||||
fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
|
||||
let widgets: std::collections::HashSet<ObjRef> = f.widgets.iter().map(|w| w.obj).collect();
|
||||
for p in page_refs(doc) {
|
||||
let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue };
|
||||
@@ -1253,33 +1591,7 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Out of the field tree: from its parent's /Kids, or from /Fields.
|
||||
let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent"));
|
||||
let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> {
|
||||
let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default();
|
||||
let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
let kept: Vec<Object> = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect();
|
||||
d.set(key.to_vec(), Object::Array(kept));
|
||||
doc.set(holder, Object::Dict(d));
|
||||
Ok(())
|
||||
};
|
||||
match parent {
|
||||
Some(p) => remove_from(doc, p, b"Kids")?,
|
||||
None => {
|
||||
let root = doc.root().ok_or(FormError::NoForm)?;
|
||||
match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) {
|
||||
Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?,
|
||||
// A form dictionary held directly in the catalog.
|
||||
Some(Object::Dict(mut af)) => {
|
||||
let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
let kept: Vec<Object> = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect();
|
||||
af.set(b"Fields".to_vec(), Object::Array(kept));
|
||||
doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
detach(doc, f.obj)?;
|
||||
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
|
||||
// widgets reachable for a full save.
|
||||
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
|
||||
|
||||
@@ -122,6 +122,12 @@ pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)>
|
||||
(run, extra)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Read names compared while merging a field's reads.
|
||||
pub(crate) static NAMES_COMPARED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's
|
||||
/// reads and issues, and Fix order's order.
|
||||
pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
|
||||
@@ -132,8 +138,11 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
|
||||
.map(|(f, _)| {
|
||||
let mut names = f.actions.calculate.reads();
|
||||
if let Some(js) = &f.actions.scripts.calculate {
|
||||
let mut known: HashSet<String> = names.iter().cloned().collect();
|
||||
for name in crate::af::calculation_reads(js) {
|
||||
if !names.contains(&name) {
|
||||
#[cfg(test)]
|
||||
NAMES_COMPARED.with(|n| n.set(n.get() + 1));
|
||||
if known.insert(name.clone()) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
@@ -234,13 +243,7 @@ fn components(links: &[Vec<usize>]) -> Vec<usize> {
|
||||
continue;
|
||||
}
|
||||
if low[v] == index[v] {
|
||||
while let Some(w) = stack.pop() {
|
||||
on_stack[w] = false;
|
||||
component[w] = count;
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pop_component(&mut stack, &mut on_stack, &mut component, v, count);
|
||||
count += 1;
|
||||
}
|
||||
if let Some(&(parent, _)) = work.last() {
|
||||
@@ -251,6 +254,16 @@ fn components(links: &[Vec<usize>]) -> Vec<usize> {
|
||||
component
|
||||
}
|
||||
|
||||
fn pop_component(stack: &mut Vec<usize>, on_stack: &mut [bool], component: &mut [usize], v: usize, count: usize) {
|
||||
while let Some(w) = stack.pop() {
|
||||
on_stack[w] = false;
|
||||
component[w] = count;
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fix order: every node after the nodes it reads in other components, the earliest ready node
|
||||
/// first, so an order that is already right stays as it is and otherwise only what must move
|
||||
/// moves. Reads within a component (a loop) are left out: no order satisfies them.
|
||||
|
||||
@@ -307,7 +307,7 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
|
||||
.iter()
|
||||
.filter(|w| w.rect[1] >= rect[3] - 1.0 && w.rect[1] - rect[3] < 24.0 && w.rect[0] < rect[2] && w.rect[2] > rect[0] - 4.0)
|
||||
.collect();
|
||||
let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(|a, b| a.total_cmp(b)) else { return String::new() };
|
||||
let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(f64::total_cmp) else { return String::new() };
|
||||
let mut on: Vec<&&Word> = above.iter().filter(|w| (mid(w.rect) - line).abs() < 3.0).collect();
|
||||
on.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
|
||||
clean(&on.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "))
|
||||
|
||||
+205
-16
@@ -24,10 +24,10 @@ mod calc_order;
|
||||
pub mod detect;
|
||||
mod scripting;
|
||||
pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, duplicate_field,
|
||||
look, redraw_field, set_button_icon, set_props,
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
@@ -48,6 +48,10 @@ pub enum FormError {
|
||||
ReadOnly(String),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
/// The value doesn't fit the field's format (its keystroke check refused it), which is
|
||||
/// checked before any of the field's scripts run.
|
||||
#[error("{0}")]
|
||||
Format(String),
|
||||
#[error("{0}")]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -932,6 +936,7 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script
|
||||
return Err(FormError::ReadOnly(name.into()));
|
||||
}
|
||||
write_value(doc, f, value, scripts)?;
|
||||
author::form_fonts_as_objects(doc, &all)?;
|
||||
// Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a
|
||||
// name as separate field dictionaries, instead of widgets under one parent, get the value
|
||||
// the first one took after its scripts, without running them again.
|
||||
@@ -1049,7 +1054,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
||||
return invalid(format!("{:?} takes at most {max} characters", f.name));
|
||||
}
|
||||
// Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value.
|
||||
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?;
|
||||
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?;
|
||||
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
|
||||
let t = &scripting::accept(doc, f, t, scripts)?;
|
||||
doc.update_dict(f.obj, |d| {
|
||||
@@ -1191,7 +1196,7 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For
|
||||
let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some();
|
||||
if !has_ap {
|
||||
let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string());
|
||||
let ap = appearance::check_box_states(doc, w, f.kind, &on_name);
|
||||
let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da);
|
||||
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
|
||||
}
|
||||
doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?;
|
||||
@@ -1293,16 +1298,158 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or
|
||||
/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and
|
||||
/// a save restores), or one of whose separate widgets carries a `/V`.
|
||||
fn password_fields(doc: &Document) -> Vec<Field> {
|
||||
if !has_password_flag(doc) {
|
||||
return Vec::new();
|
||||
}
|
||||
let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some()));
|
||||
let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first();
|
||||
fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect()
|
||||
}
|
||||
|
||||
/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the
|
||||
/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag
|
||||
/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save
|
||||
/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like
|
||||
/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be
|
||||
/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep.
|
||||
fn has_password_flag(doc: &Document) -> bool {
|
||||
let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0);
|
||||
let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default();
|
||||
let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some((o, depth)) = stack.pop() {
|
||||
let Object::Ref(r) = o else { continue };
|
||||
if depth > 32 || !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if sets_it(r) {
|
||||
return true;
|
||||
}
|
||||
let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default();
|
||||
stack.extend(kids.into_iter().map(|k| (k, depth + 1)));
|
||||
}
|
||||
// Widgets on the pages, and the fields above them.
|
||||
page_refs(doc).into_iter().any(|p| {
|
||||
let annots =
|
||||
doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default();
|
||||
annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any text field with the Password flag holds a value in `doc`.
|
||||
pub fn has_password_values(doc: &Document) -> bool {
|
||||
!password_fields(doc).is_empty()
|
||||
}
|
||||
|
||||
/// The typed values of the text fields with the Password flag, as (name, text) pairs, for
|
||||
/// [`restore_password_values`] after the document was rebased on a file written without them.
|
||||
pub fn password_values(doc: &Document) -> Vec<(String, String)> {
|
||||
password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect()
|
||||
}
|
||||
|
||||
/// Put values from [`password_values`] back into the open document, in memory only. A field
|
||||
/// that is gone or read-only keeps what it has.
|
||||
pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> {
|
||||
for (name, v) in values {
|
||||
match set_value(doc, name, &FieldValue::Text(v.clone())) {
|
||||
Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats.
|
||||
fn field_chain(doc: &Document, r: ObjRef) -> Vec<ObjRef> {
|
||||
let mut chain = vec![r];
|
||||
let mut cur = r;
|
||||
while chain.len() < 32 {
|
||||
let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break };
|
||||
if chain.contains(&parent) {
|
||||
break;
|
||||
}
|
||||
chain.push(parent);
|
||||
cur = parent;
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Remove the values of the text fields with the Password flag, for a copy of the document about
|
||||
/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is
|
||||
/// imperative that PDF processors never store the value of the text field in the PDF file if
|
||||
/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with
|
||||
/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without
|
||||
/// is left empty, and its appearance is drawn again from that value, so the masking characters
|
||||
/// no longer show how long the typed password was. `/DV` and the field's scripts stay.
|
||||
///
|
||||
/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from
|
||||
/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it
|
||||
/// is such a password field. Where an ancestor's value is shared with other fields and stays, the
|
||||
/// password field gets an empty value of its own so it no longer inherits it. Returns how many
|
||||
/// fields were cleared.
|
||||
pub fn clear_password_values(doc: &mut Document) -> Result<usize, FormError> {
|
||||
let targets = password_fields(doc);
|
||||
if targets.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD);
|
||||
// Each field's ancestors, found once: only values change below, never `/Parent` (#3943
|
||||
// review: walking them again for every ancestor of every target was quadratic).
|
||||
let chains: Vec<(bool, Vec<ObjRef>)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect();
|
||||
for f in &targets {
|
||||
let chain = field_chain(doc, f.obj);
|
||||
for w in &f.widgets {
|
||||
if w.obj != f.obj {
|
||||
doc.update_dict(w.obj, |d| {
|
||||
d.remove(b"V");
|
||||
})?;
|
||||
}
|
||||
}
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.remove(b"V");
|
||||
})?;
|
||||
let mut shared_value_kept = false;
|
||||
for a in chain.iter().skip(1) {
|
||||
if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) {
|
||||
continue;
|
||||
}
|
||||
let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password);
|
||||
if exclusive {
|
||||
doc.update_dict(*a, |d| {
|
||||
d.remove(b"V");
|
||||
})?;
|
||||
} else {
|
||||
shared_value_kept = true;
|
||||
}
|
||||
}
|
||||
let restored: Vec<String> = f.default.first().cloned().into_iter().collect();
|
||||
match restored.first() {
|
||||
Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?,
|
||||
None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?,
|
||||
None => {}
|
||||
}
|
||||
redraw(doc, f, &restored, &mut NoScripts)?;
|
||||
}
|
||||
Ok(targets.len())
|
||||
}
|
||||
|
||||
/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`).
|
||||
pub fn need_appearances(doc: &Document) -> bool {
|
||||
acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))))
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which text and choice fields are drawn from their values, for displaying
|
||||
/// the document (`None` when no widget needs it): every such widget when the form sets
|
||||
/// `/NeedAppearances` (ISO 32000-2 Table 224: the writer left the appearances to the viewer),
|
||||
/// and otherwise each widget without a normal appearance (`/AP /N`). Drawn as typing the value
|
||||
/// would draw it (#3470). `doc` is not changed: saving writes the file as it was.
|
||||
/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for
|
||||
/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from
|
||||
/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the
|
||||
/// writer left the appearances to the viewer), and otherwise each widget without a normal
|
||||
/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons
|
||||
/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border
|
||||
/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not
|
||||
/// changed: saving writes the file as it was.
|
||||
pub fn with_field_appearances(doc: &Document) -> Option<Document> {
|
||||
with_field_appearances_cached(doc, &mut FieldAppearanceCache::default())
|
||||
}
|
||||
@@ -1312,18 +1459,45 @@ pub fn with_field_appearances(doc: &Document) -> Option<Document> {
|
||||
pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option<Document> {
|
||||
let all = need_appearances(doc);
|
||||
let mut copy: Option<Document> = None;
|
||||
for f in fields(doc).iter().filter(|f| matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List)) {
|
||||
for f in fields(doc) {
|
||||
for w in &f.widgets {
|
||||
let widget = doc.get(w.obj);
|
||||
let drawn = widget.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
|
||||
if drawn && !all {
|
||||
let normal = widget
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"AP"))
|
||||
.and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n))));
|
||||
// A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`,
|
||||
// else a check box's value, else Off. An Off state may go without one (it shows
|
||||
// nothing); an on state needs one.
|
||||
let state = match f.kind {
|
||||
FieldKind::CheckBox | FieldKind::Radio => {
|
||||
let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off");
|
||||
Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let missing = match f.kind {
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all,
|
||||
FieldKind::PushButton => normal.is_none(),
|
||||
FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) {
|
||||
(None, _) => true,
|
||||
(Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)),
|
||||
_ => false,
|
||||
},
|
||||
FieldKind::Signature => false,
|
||||
};
|
||||
if !missing {
|
||||
continue;
|
||||
}
|
||||
let inputs = appearance_inputs(doc, w);
|
||||
let stream = match cache.get(w.obj, &inputs) {
|
||||
Some(stream) => stream,
|
||||
None => {
|
||||
let (stream, _) = appearance::field_appearance_drawn(doc, f, w, &f.value, true);
|
||||
let stream = match f.kind {
|
||||
FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")),
|
||||
FieldKind::PushButton => appearance::push_button_shown(doc, &f, w),
|
||||
_ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0,
|
||||
};
|
||||
cache.insert(w.obj, inputs, &stream);
|
||||
stream
|
||||
}
|
||||
@@ -1331,10 +1505,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
|
||||
let target = copy.get_or_insert_with(|| doc.clone());
|
||||
let ap = target.add(Object::Stream(stream));
|
||||
let mut fresh = Dict::new();
|
||||
fresh.set(b"N".to_vec(), Object::Ref(ap));
|
||||
match &state {
|
||||
// The drawn look under its state's name, and that state as the widget's own.
|
||||
Some(s) => {
|
||||
let mut states = Dict::new();
|
||||
states.set(name_bytes(s), Object::Ref(ap));
|
||||
fresh.set(b"N".to_vec(), Object::Dict(states));
|
||||
}
|
||||
None => fresh.set(b"N".to_vec(), Object::Ref(ap)),
|
||||
}
|
||||
let apd = merged_appearance(target, w.obj, &fresh);
|
||||
// Fails only when the widget isn't a dictionary; that widget then keeps its own look.
|
||||
target.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(apd))).ok();
|
||||
target
|
||||
.update_dict(w.obj, |d| {
|
||||
d.set(b"AP".to_vec(), Object::Dict(apd));
|
||||
if let Some(s) = &state {
|
||||
d.set(b"AS".to_vec(), Object::Name(name_bytes(s)));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
copy
|
||||
|
||||
@@ -269,23 +269,28 @@ pub fn cell_name(pattern: &str, row: usize, column: usize, seq: usize) -> String
|
||||
if numbered {
|
||||
return out;
|
||||
}
|
||||
substitute_isolated_n(&out, &row_number)
|
||||
}
|
||||
|
||||
fn substitute_isolated_n(out: &str, row_number: &str) -> String {
|
||||
if out == "N" || out == "n" {
|
||||
return row_number;
|
||||
return row_number.to_string();
|
||||
}
|
||||
let chars: Vec<char> = out.chars().collect();
|
||||
let mut named = String::with_capacity(out.len() + row_number.len());
|
||||
let mut numbered = false;
|
||||
for (i, &ch) in chars.iter().enumerate() {
|
||||
let after_separator = i.checked_sub(1).and_then(|p| chars.get(p)).is_some_and(|c| SEPARATORS.contains(c));
|
||||
let before_separator = chars.get(i + 1).is_none_or(|c| SEPARATORS.contains(c));
|
||||
if matches!(ch, 'N' | 'n') && after_separator && before_separator {
|
||||
named.push_str(&row_number);
|
||||
named.push_str(row_number);
|
||||
numbered = true;
|
||||
} else {
|
||||
named.push(ch);
|
||||
}
|
||||
}
|
||||
if !numbered {
|
||||
named.push_str(&row_number);
|
||||
named.push_str(row_number);
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
+1219
-1
File diff suppressed because it is too large.
Load diff
@@ -123,7 +123,7 @@ fn lex(src: &str) -> Result<Vec<Token>, String> {
|
||||
}
|
||||
out.push(Token { tok: Tok::Str(s), line });
|
||||
}
|
||||
c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
|
||||
c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => {
|
||||
let start = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) {
|
||||
i += 1;
|
||||
@@ -716,6 +716,8 @@ fn num_text(n: f64) -> String {
|
||||
fn fixed(v: f64, decimals: usize) -> String {
|
||||
let k = 10f64.powi(decimals.min(20) as i32);
|
||||
let scaled = (v * k).round() / k;
|
||||
// A small negative value rounds to -0.0, which would print as "-0".
|
||||
let scaled = if scaled == 0.0 { 0.0 } else { scaled };
|
||||
format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v })
|
||||
}
|
||||
|
||||
@@ -758,7 +760,7 @@ fn parse_num(s: &str) -> Option<f64> {
|
||||
}
|
||||
|
||||
fn is_numeric_text(s: &str) -> bool {
|
||||
s.trim().parse::<f64>().is_ok_and(|v| v.is_finite())
|
||||
s.trim().parse::<f64>().is_ok_and(f64::is_finite)
|
||||
}
|
||||
|
||||
// ── the evaluator ───────────────────────────────────────────────────────────────────────────
|
||||
@@ -1946,7 +1948,7 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> {
|
||||
}
|
||||
|
||||
/// Number of days in month `m` (1-12) of year `y`, leap years included.
|
||||
fn days_in(y: i64, m: u32) -> u32 {
|
||||
pub(crate) fn days_in(y: i64, m: u32) -> u32 {
|
||||
// The length of the month from chrono (the first of the next month, less the first of this
|
||||
// one). An invalid month keeps the old 31-day default.
|
||||
// Month lengths repeat every 400 years, so the year within its cycle is enough (and always
|
||||
@@ -2323,10 +2325,11 @@ impl Interp<'_> {
|
||||
}
|
||||
text = if frac.is_empty() { g } else { format!("{g}.{frac}") };
|
||||
}
|
||||
let nonzero = text.chars().any(|c| ('1'..='9').contains(&c));
|
||||
if pic.contains('$') {
|
||||
text = format!("${text}");
|
||||
}
|
||||
if num < 0.0 {
|
||||
if num < 0.0 && nonzero {
|
||||
text = format!("-{text}");
|
||||
}
|
||||
Val::Str(text)
|
||||
@@ -2520,10 +2523,10 @@ impl Interp<'_> {
|
||||
let v = parse_num(&s).unwrap_or(0.0);
|
||||
let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect();
|
||||
let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() {
|
||||
"mm" => 25.4,
|
||||
"cm" => 2.54,
|
||||
"pt" => 72.0,
|
||||
"pc" => 6.0,
|
||||
"mm" | "millimeter" | "millimeters" => 25.4,
|
||||
"cm" | "centimeter" | "centimeters" => 2.54,
|
||||
"pt" | "point" | "points" => 72.0,
|
||||
"pc" | "pica" => 6.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let target = if to.trim().is_empty() { unit.clone() } else { to };
|
||||
@@ -2627,6 +2630,14 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_does_not_print_negative_zero() {
|
||||
assert_eq!(fixed(-0.4, 0), "0");
|
||||
assert_eq!(fixed(-0.004, 2), "0.00");
|
||||
assert_eq!(fixed(-0.0, 0), "0");
|
||||
assert_eq!(fixed(-1.5, 0), "-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_round_trip_through_formcalc_day_numbers() {
|
||||
assert_eq!(days_from_civil(1900, 1, 1), 1);
|
||||
|
||||
+453
-11
@@ -13,8 +13,8 @@
|
||||
//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`,
|
||||
//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`,
|
||||
//! `setFocus`.
|
||||
//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`,
|
||||
//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`,
|
||||
//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`,
|
||||
//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`,
|
||||
//! `show`, `clear`), `display` and `color`.
|
||||
//!
|
||||
//! The engine is a sandbox: no file, network or timer access exists, and loops and recursion
|
||||
@@ -28,7 +28,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use boa_engine::context::HostHooks;
|
||||
use boa_engine::object::builtins::{JsArray, JsFunction};
|
||||
use boa_engine::object::builtins::{JsArray, JsDate, JsFunction};
|
||||
use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer};
|
||||
use boa_engine::property::Attribute;
|
||||
use boa_engine::realm::Realm;
|
||||
@@ -144,6 +144,8 @@ pub struct DocInfo {
|
||||
pub page: usize,
|
||||
/// Document information (`this.info.title` etc.): lower-case key, value.
|
||||
pub info: Vec<(String, String)>,
|
||||
/// The document has an XFA form, so scripts see `xfa_installed`.
|
||||
pub xfa: bool,
|
||||
}
|
||||
|
||||
/// Something a script asked the viewer to do.
|
||||
@@ -162,6 +164,36 @@ pub enum Request {
|
||||
Beep,
|
||||
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
|
||||
SaveAs,
|
||||
/// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing.
|
||||
ShowPanel(Panel),
|
||||
}
|
||||
|
||||
/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Panel {
|
||||
/// The document's bookmarks (outline).
|
||||
Bookmarks,
|
||||
/// Page thumbnails.
|
||||
Pages,
|
||||
/// The files attached to the document.
|
||||
Attachments,
|
||||
/// The document's signatures and their validity.
|
||||
Signatures,
|
||||
/// The document's layers (optional content).
|
||||
Layers,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
/// The panel's name in automation output, fixed whatever the variant is called.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Panel::Bookmarks => "bookmarks",
|
||||
Panel::Pages => "pages",
|
||||
Panel::Attachments => "attachments",
|
||||
Panel::Signatures => "signatures",
|
||||
Panel::Layers => "layers",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What running a script did.
|
||||
@@ -179,6 +211,9 @@ pub struct Outcome {
|
||||
pub requests: Vec<Request>,
|
||||
/// The script's error (syntax error, exception, or a limit reached), if it failed.
|
||||
pub error: Option<String>,
|
||||
/// Errors in the document-level scripts that ran first. Each is a script of its own: one
|
||||
/// that throws stops where it failed, and the others and the script itself still run (#3940).
|
||||
pub doc_errors: Vec<String>,
|
||||
/// The script's completion value (what the console prints), unless undefined.
|
||||
pub result: Option<String>,
|
||||
}
|
||||
@@ -624,6 +659,8 @@ fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and
|
||||
/// returns 1 (the OK button).
|
||||
fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = message(&arg(args, 0), "cMsg", ctx)?;
|
||||
host(ctx)?.borrow_mut().alert(m);
|
||||
@@ -631,17 +668,49 @@ fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
|
||||
Ok(JsValue::from(1))
|
||||
}
|
||||
|
||||
/// `app.beep()`: queues a request to beep.
|
||||
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
host(ctx)?.borrow_mut().request(Request::Beep);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object
|
||||
/// with `cURL`.
|
||||
fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let u = message(&arg(args, 0), "cURL", ctx)?;
|
||||
host(ctx)?.borrow_mut().request(Request::LaunchUrl(u));
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print
|
||||
/// and Save As. Other items do nothing.
|
||||
pub(crate) fn menu_item(name: &str) -> Option<Request> {
|
||||
Some(match name {
|
||||
"ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments),
|
||||
"ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks),
|
||||
"ShowHideThumbnails" => Request::ShowPanel(Panel::Pages),
|
||||
"ShowHideSignatures" => Request::ShowPanel(Panel::Signatures),
|
||||
"ShowHideOptCont" => Request::ShowPanel(Panel::Layers),
|
||||
"Print" => Request::Print,
|
||||
// Save opens Save As too, as it always has for XFA forms' buttons: a click on a form
|
||||
// never overwrites the file unasked.
|
||||
"SaveAs" | "Save" => Request::SaveAs,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens
|
||||
/// Save As too); an unknown name does nothing.
|
||||
fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let item = text(&arg(args, 0), ctx)?;
|
||||
if let Some(r) = menu_item(&item) {
|
||||
host(ctx)?.borrow_mut().request(r);
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to
|
||||
/// type one.
|
||||
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
// No one to answer: as if the dialog was cancelled.
|
||||
Ok(JsValue::null())
|
||||
@@ -883,6 +952,324 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
|
||||
Ok(s(&out))
|
||||
}
|
||||
|
||||
/// A field of a `util.scand` format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DateUnit {
|
||||
/// `yyyy`: four digits.
|
||||
Year,
|
||||
/// `yy`: two digits, read as 1950–2049.
|
||||
ShortYear,
|
||||
Month,
|
||||
/// `mmm`, `mmmm`: a month's name or its three-letter abbreviation.
|
||||
MonthName,
|
||||
Day,
|
||||
/// `ddd`, `dddd`: a weekday's name, read and not checked.
|
||||
Weekday,
|
||||
Hour,
|
||||
/// `h`, `hh`: 0–12, with `tt` for the afternoon.
|
||||
Hour12,
|
||||
Minute,
|
||||
Second,
|
||||
/// `t`, `tt`: am or pm.
|
||||
AmPm,
|
||||
}
|
||||
|
||||
impl DateUnit {
|
||||
/// A year, month or day: a format needs one of these for `util.scand` to read anything.
|
||||
fn is_date(self) -> bool {
|
||||
matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day)
|
||||
}
|
||||
|
||||
/// Read from a run of digits, not from letters.
|
||||
fn is_numeric(self) -> bool {
|
||||
!matches!(self, Self::MonthName | Self::Weekday | Self::AmPm)
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of a `util.scand` format.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ScanPart {
|
||||
/// A field, the digits it takes when typed together with the next one (`width`), and whether
|
||||
/// the next one follows it with nothing between them in the format (`joined`).
|
||||
Field { unit: DateUnit, width: usize, joined: bool },
|
||||
/// Letters the format writes as they are, which the text must have there.
|
||||
Word(String),
|
||||
}
|
||||
|
||||
/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked
|
||||
/// `joined` when another numeric field follows it directly; anything but letters and digits
|
||||
/// outside a field is left out.
|
||||
fn scan_parts(fmt: &str) -> Vec<ScanPart> {
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut parts: Vec<ScanPart> = Vec::new();
|
||||
// The last part is a field written right before this point, or letters still being collected.
|
||||
let (mut after_field, mut in_word) = (false, false);
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
// Counted for field letters only, so a long run of anything else is read once.
|
||||
let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 };
|
||||
let field = match (c, run) {
|
||||
('y', 4..) => Some((DateUnit::Year, 4)),
|
||||
('y', _) => Some((DateUnit::ShortYear, 2)),
|
||||
('m', 3..) => Some((DateUnit::MonthName, 0)),
|
||||
('m', n) => Some((DateUnit::Month, n)),
|
||||
('d', 3..) => Some((DateUnit::Weekday, 0)),
|
||||
('d', n) => Some((DateUnit::Day, n)),
|
||||
('H', n) => Some((DateUnit::Hour, n.min(2))),
|
||||
('h', n) => Some((DateUnit::Hour12, n.min(2))),
|
||||
('M', n) => Some((DateUnit::Minute, n.min(2))),
|
||||
('s', n) => Some((DateUnit::Second, n.min(2))),
|
||||
('t', _) => Some((DateUnit::AmPm, 0)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((unit, width)) = field {
|
||||
if after_field
|
||||
&& unit.is_numeric()
|
||||
&& let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut()
|
||||
&& last.is_numeric()
|
||||
{
|
||||
*joined = true;
|
||||
}
|
||||
parts.push(ScanPart::Field { unit, width, joined: false });
|
||||
(after_field, in_word) = (true, false);
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
// `\` writes the next character as it is.
|
||||
let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) };
|
||||
i += step;
|
||||
after_field = false;
|
||||
match literal {
|
||||
Some(l) if l.is_alphanumeric() => match parts.last_mut() {
|
||||
Some(ScanPart::Word(w)) if in_word => w.push(l),
|
||||
_ => {
|
||||
parts.push(ScanPart::Word(l.to_string()));
|
||||
in_word = true;
|
||||
}
|
||||
},
|
||||
_ => in_word = false,
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// A run of digits or of letters in the text `util.scand` reads.
|
||||
enum ScanToken<'a> {
|
||||
Digits(&'a str),
|
||||
Letters(&'a str),
|
||||
}
|
||||
|
||||
/// The text's runs of digits and of letters, in order; anything else only separates them, and
|
||||
/// may not begin or end the text. `None` when it does.
|
||||
fn scan_tokens(text: &str) -> Option<Vec<ScanToken<'_>>> {
|
||||
// Some(true) for a digit, Some(false) for a letter, None for a separator.
|
||||
let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit());
|
||||
kind(text.chars().next()?)?;
|
||||
kind(text.chars().next_back()?)?;
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<(usize, bool)> = None;
|
||||
for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) {
|
||||
let k = kind(c);
|
||||
if let Some((from, digits)) = start
|
||||
&& k != Some(digits)
|
||||
{
|
||||
let piece = text.get(from..at)?;
|
||||
out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) });
|
||||
start = None;
|
||||
}
|
||||
if let Some(digits) = k
|
||||
&& start.is_none()
|
||||
{
|
||||
start = Some((at, digits));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// What `util.scand` reads.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Scanned {
|
||||
/// The text is empty: today, at noon.
|
||||
Today,
|
||||
/// A date and time: year, month (1–12), day, hours, minutes, seconds.
|
||||
At(i32, u32, u32, u32, u32, u32),
|
||||
}
|
||||
|
||||
/// The fields `util.scand` has read so far.
|
||||
#[derive(Default)]
|
||||
struct ScanValues {
|
||||
y: Option<i32>,
|
||||
mo: Option<u32>,
|
||||
d: Option<u32>,
|
||||
h: Option<u32>,
|
||||
mi: Option<u32>,
|
||||
sec: Option<u32>,
|
||||
pm: Option<bool>,
|
||||
/// The hour was read from `h` or `hh`.
|
||||
twelve: bool,
|
||||
}
|
||||
|
||||
impl ScanValues {
|
||||
/// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour
|
||||
/// marked as 12-hour. Month names, weekdays and am/pm are not read here.
|
||||
fn set(&mut self, unit: DateUnit, v: u32) {
|
||||
match unit {
|
||||
DateUnit::Year => self.y = i32::try_from(v).ok(),
|
||||
DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }),
|
||||
DateUnit::Month => self.mo = Some(v),
|
||||
DateUnit::Day => self.d = Some(v),
|
||||
DateUnit::Hour => self.h = Some(v),
|
||||
DateUnit::Hour12 => {
|
||||
self.h = Some(v);
|
||||
self.twelve = true;
|
||||
}
|
||||
DateUnit::Minute => self.mi = Some(v),
|
||||
DateUnit::Second => self.sec = Some(v),
|
||||
DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box):
|
||||
///
|
||||
/// - Fields may be separated by anything but digits and letters, whether or not the format
|
||||
/// separates them, but nothing may come before the first or after the last.
|
||||
/// - Digits typed together fill the format's fields written together with exactly as many
|
||||
/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may
|
||||
/// have one or two, except a four-digit year.
|
||||
/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case.
|
||||
/// - Impossible dates are refused, but a month or day of 0 is read as 1.
|
||||
/// - A format with no year, month or day reads nothing; an empty text is today, at noon.
|
||||
///
|
||||
/// Not observed: a year left out is `this_year`, and a month or day left out is 1.
|
||||
fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option<Scanned> {
|
||||
let parts = scan_parts(fmt);
|
||||
if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) {
|
||||
return None;
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Some(Scanned::Today);
|
||||
}
|
||||
let tokens = scan_tokens(text)?;
|
||||
let mut v = ScanValues::default();
|
||||
let (mut p, mut t) = (0, 0);
|
||||
while let Some(part) = parts.get(p) {
|
||||
let token = tokens.get(t)?;
|
||||
t += 1;
|
||||
match (part, token) {
|
||||
(ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => {
|
||||
if w.to_lowercase() != l.to_lowercase() {
|
||||
return None;
|
||||
}
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => {
|
||||
v.mo = Some(named(&MONTHS, l)?.saturating_add(1));
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => {
|
||||
named(&DAYS, l)?;
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => {
|
||||
v.pm = Some(match l.to_lowercase().as_str() {
|
||||
"am" | "a" => false,
|
||||
"pm" | "p" => true,
|
||||
_ => return None,
|
||||
});
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => {
|
||||
// The fields written together from here, no more than the run's digits can fill,
|
||||
// and the fewest of them the run fills.
|
||||
let mut group = vec![(*unit, *width)];
|
||||
let (mut more, mut widths) = (*joined, *width);
|
||||
while more
|
||||
&& widths < run.len()
|
||||
&& let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len())
|
||||
{
|
||||
group.push((*unit, *width));
|
||||
widths = widths.saturating_add(*width);
|
||||
more = *joined;
|
||||
}
|
||||
let mut sum = 0;
|
||||
let fills = group.iter().position(|(_, w)| {
|
||||
sum += w;
|
||||
sum == run.len()
|
||||
});
|
||||
if let Some(last) = fills {
|
||||
let mut at = 0;
|
||||
for (unit, w) in group.iter().take(last + 1) {
|
||||
v.set(*unit, run.get(at..at + w)?.parse().ok()?);
|
||||
at += w;
|
||||
}
|
||||
p += last + 1;
|
||||
} else if *unit != DateUnit::Year && run.len() <= 2 {
|
||||
v.set(*unit, run.parse().ok()?);
|
||||
p += 1;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if t != tokens.len() {
|
||||
return None;
|
||||
}
|
||||
let y = v.y.unwrap_or(this_year);
|
||||
let mo = v.mo.unwrap_or(1).max(1);
|
||||
let d = v.d.unwrap_or(1).max(1);
|
||||
if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) {
|
||||
return None;
|
||||
}
|
||||
let mut h = v.h.unwrap_or(0);
|
||||
if v.twelve && h > 12 {
|
||||
return None;
|
||||
}
|
||||
match v.pm {
|
||||
Some(true) if h < 12 => h += 12,
|
||||
Some(false) if h == 12 => h = 0,
|
||||
_ => {}
|
||||
}
|
||||
let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0));
|
||||
(h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec))
|
||||
}
|
||||
|
||||
/// The index of the name `word` is, in full or as its first three letters, in any case.
|
||||
fn named(names: &[&str], word: &str) -> Option<u32> {
|
||||
let word = word.to_lowercase();
|
||||
let i = names.iter().position(|n| {
|
||||
let n = n.to_lowercase();
|
||||
word == n || n.get(..3) == Some(word.as_str())
|
||||
})?;
|
||||
u32::try_from(i).ok()
|
||||
}
|
||||
|
||||
/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local
|
||||
/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read.
|
||||
fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else {
|
||||
return Ok(JsValue::null());
|
||||
};
|
||||
let fmt = text(&arg(args, 0), ctx)?;
|
||||
let date = JsDate::new(ctx);
|
||||
let this_year = date.get_full_year(ctx)?.to_number(ctx)?;
|
||||
let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 };
|
||||
match scan_date(&fmt, &typed, this_year) {
|
||||
None => return Ok(JsValue::null()),
|
||||
Some(Scanned::Today) => {
|
||||
date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?;
|
||||
}
|
||||
Some(Scanned::At(y, mo, d, h, mi, sec)) => {
|
||||
// setFullYear, not the constructor, so years 0–99 stay as they are.
|
||||
date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?;
|
||||
date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?;
|
||||
}
|
||||
}
|
||||
Ok(date.into())
|
||||
}
|
||||
|
||||
/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9`
|
||||
/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is.
|
||||
fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
@@ -948,6 +1335,8 @@ fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
|
||||
Ok(s(&out))
|
||||
}
|
||||
|
||||
/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as
|
||||
/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components.
|
||||
fn color_object(ctx: &mut Context) -> JsObject {
|
||||
let mut entries: Vec<(&str, Vec<JsValue>)> = vec![
|
||||
("transparent", vec![s("T")]),
|
||||
@@ -971,6 +1360,13 @@ fn color_object(ctx: &mut Context) -> JsObject {
|
||||
o.build()
|
||||
}
|
||||
|
||||
/// The XFA version scripts see as `xfa_version`.
|
||||
const XFA_VERSION: f64 = 3.5;
|
||||
|
||||
/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`,
|
||||
/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`,
|
||||
/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and
|
||||
/// `color` objects.
|
||||
fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let globals: [(&str, usize, NativeFunction); 9] = [
|
||||
("getField", 1, NativeFunction::from_fn_ptr(get_field)),
|
||||
@@ -991,9 +1387,9 @@ fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields));
|
||||
let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get));
|
||||
let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set));
|
||||
let (file, pages, info) = {
|
||||
let (file, pages, info, xfa) = {
|
||||
let h = host(ctx)?.borrow();
|
||||
(h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone())
|
||||
(h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa)
|
||||
};
|
||||
let mut io = ObjectInitializer::new(ctx);
|
||||
for (k, v) in &info {
|
||||
@@ -1006,12 +1402,19 @@ fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?;
|
||||
ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?;
|
||||
ctx.register_global_property(js_string!("info"), info, rw)?;
|
||||
// XFA forms check both before they run: every document sees the XFA version, and only an
|
||||
// XFA form sees `xfa_installed`.
|
||||
ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?;
|
||||
if xfa {
|
||||
ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?;
|
||||
}
|
||||
|
||||
let app = ObjectInitializer::new(ctx)
|
||||
.function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1)
|
||||
@@ -1028,6 +1431,7 @@ fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
.function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2)
|
||||
.build();
|
||||
ctx.register_global_property(js_string!("util"), util, rw)?;
|
||||
let console = ObjectInitializer::new(ctx)
|
||||
@@ -1191,16 +1595,31 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do
|
||||
/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form.
|
||||
pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() };
|
||||
if let Some(why) = std::iter::once(script).chain(doc_scripts.iter().map(String::as_str)).find_map(refuse) {
|
||||
if let Some(why) = refuse(script) {
|
||||
return failed(why);
|
||||
}
|
||||
// A document script too long or too deeply nested to run safely is skipped and reported,
|
||||
// as one that throws is; the others and the script still run (#3940).
|
||||
let mut skipped = Vec::new();
|
||||
let kept: Vec<(usize, &str)> = doc_scripts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, d)| match refuse(d) {
|
||||
Some(why) => {
|
||||
skipped.push((i, why));
|
||||
None
|
||||
}
|
||||
None => Some((i, d.as_str())),
|
||||
})
|
||||
.collect();
|
||||
let (kept, skipped) = (&kept, &skipped);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-js".into())
|
||||
.stack_size(SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, fields, doc_scripts, limits));
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits));
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
@@ -1208,7 +1627,7 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
run_here(script, event, doc, fields, doc_scripts, limits)
|
||||
run_here(script, event, doc, fields, kept, skipped, limits)
|
||||
}
|
||||
|
||||
/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator
|
||||
@@ -1243,7 +1662,22 @@ impl HostHooks for CheckedCodeFromStrings {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place.
|
||||
fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String {
|
||||
format!("document script {}: {error}", index.saturating_add(1))
|
||||
}
|
||||
|
||||
/// `doc_scripts` are the document scripts to run with their places among all of them;
|
||||
/// `skipped` the ones refused before running, with the reason.
|
||||
fn run_here(
|
||||
script: &str,
|
||||
event: &Event,
|
||||
doc: &DocInfo,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
doc_scripts: &[(usize, &str)],
|
||||
skipped: &[(usize, String)],
|
||||
limits: Limits,
|
||||
) -> Outcome {
|
||||
let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
@@ -1264,12 +1698,18 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
|
||||
dropped: 0,
|
||||
}));
|
||||
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
|
||||
let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
|
||||
let result = (|| -> JsResult<()> {
|
||||
install(&mut ctx)?;
|
||||
let ev = event_object(&mut ctx, event);
|
||||
ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?;
|
||||
for d in doc_scripts {
|
||||
ctx.eval(Source::from_bytes(d))?;
|
||||
// One document script that throws (calling a method PdfCraft lacks, say) must not keep
|
||||
// every field script in the form from running: what it defined before the error, and
|
||||
// the other scripts, stay available (#3940).
|
||||
for &(i, d) in doc_scripts {
|
||||
if let Err(e) = ctx.eval(Source::from_bytes(d)) {
|
||||
doc_errors.push((i, doc_error(i, &e)));
|
||||
}
|
||||
}
|
||||
let r = ctx.eval(Source::from_bytes(script))?;
|
||||
if !r.is_undefined() {
|
||||
@@ -1286,6 +1726,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
|
||||
if let Err(e) = result {
|
||||
out.error = Some(e.to_string());
|
||||
}
|
||||
doc_errors.sort_by_key(|(i, _)| *i);
|
||||
out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect();
|
||||
if let Some(h) = ctx.remove_data::<Shared>() {
|
||||
let h = h.into_inner();
|
||||
out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect();
|
||||
|
||||
+357
-3
@@ -16,7 +16,7 @@ fn fields() -> Vec<FieldState> {
|
||||
}
|
||||
|
||||
fn doc() -> DocInfo {
|
||||
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())] }
|
||||
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], xfa: false }
|
||||
}
|
||||
|
||||
fn go(script: &str, event: &Event) -> Outcome {
|
||||
@@ -254,6 +254,13 @@ fn kept_bytes(o: &Outcome) -> usize {
|
||||
o.console.iter().chain(&o.alerts).map(String::len).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_menu_item_requests_are_capped_like_other_requests() {
|
||||
let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.requests.len(), crate::xfa::MAX_EFFECTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_alerts_and_requests_are_capped_in_count_and_bytes() {
|
||||
let o = go(
|
||||
@@ -297,6 +304,61 @@ fn document_level_functions_are_available() {
|
||||
assert_eq!(o.value, "8");
|
||||
}
|
||||
|
||||
/// #3940: a document script that throws after defining a helper (here calling an Acrobat method
|
||||
/// PdfCraft lacks) still leaves the helper, and the later scripts, to the field script.
|
||||
#[test]
|
||||
fn a_failing_document_script_does_not_stop_the_field_script() {
|
||||
let docs = [
|
||||
"function double(x) { return x * 2; }\nthis.getField('qty').buttonSetIcon(null);\nvar after = 1;".to_string(),
|
||||
"function plus(x) { return x + 1; }".to_string(),
|
||||
"this is not javascript".to_string(),
|
||||
];
|
||||
let o = run(
|
||||
"event.value = plus(double(getField('qty').value));",
|
||||
&Event::field("Calculate", "total", ""),
|
||||
&doc(),
|
||||
&fields(),
|
||||
&docs,
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "9"));
|
||||
assert_eq!(o.doc_errors.len(), 2, "{:?}", o.doc_errors);
|
||||
assert!(o.doc_errors[0].starts_with("document script 1: TypeError"), "{:?}", o.doc_errors);
|
||||
assert!(o.doc_errors[1].starts_with("document script 3: SyntaxError"), "{:?}", o.doc_errors);
|
||||
// The failing script stopped where it failed.
|
||||
let o = run("event.value = typeof after;", &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default());
|
||||
assert_eq!(o.value, "undefined");
|
||||
// Working document scripts report nothing.
|
||||
assert!(go("event.value = 1;", &Event::field("Calculate", "total", "")).doc_errors.is_empty());
|
||||
}
|
||||
|
||||
/// A document script refused before running (too long, or nested too deeply to run safely) is
|
||||
/// skipped like one that throws: never run, reported in its place, the rest still run.
|
||||
#[test]
|
||||
fn a_document_script_too_big_to_run_is_skipped_and_reported() {
|
||||
let docs = [
|
||||
"this.getField('qty').buttonSetIcon(null);".to_string(),
|
||||
format!("var big = 1{};", "+1".repeat(MAX_SCRIPT_BYTES / 2)),
|
||||
"function double(x) { return x * 2; }".to_string(),
|
||||
format!("var deep = {}1{};", "(".repeat(10_000), ")".repeat(10_000)),
|
||||
];
|
||||
let o = run(
|
||||
"event.value = double(getField('qty').value) + typeof big;",
|
||||
&Event::field("Calculate", "total", ""),
|
||||
&doc(),
|
||||
&fields(),
|
||||
&docs,
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "8undefined"));
|
||||
let places: Vec<&str> = o.doc_errors.iter().map(|e| e.split(':').next().unwrap_or_default()).collect();
|
||||
assert_eq!(places, ["document script 1", "document script 2", "document script 4"], "{:?}", o.doc_errors);
|
||||
assert!(o.doc_errors[1].contains("too long") && o.doc_errors[2].contains("nested too deeply"), "{:?}", o.doc_errors);
|
||||
// The field script's own limits still refuse it outright.
|
||||
let o = run(&format!("1{}", "+1".repeat(MAX_SCRIPT_BYTES)), &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default());
|
||||
assert!(o.error.is_some_and(|e| e.contains("too long")) && o.doc_errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_runaway_scripts_are_contained() {
|
||||
let o = go("event.value = nosuch.thing;", &Event::field("Calculate", "total", "keep"));
|
||||
@@ -336,9 +398,11 @@ fn deeply_nested_scripts_fail_instead_of_crashing() {
|
||||
let o = go(s, &Event::doc("Open"));
|
||||
assert!(o.error.is_some(), "{}…", &s[..20]);
|
||||
}
|
||||
// A hostile document-level script is refused too, before the field script runs.
|
||||
// A hostile document-level script is refused too: it never runs, and is reported, while
|
||||
// the field script still does (#3940).
|
||||
let o = run("event.value = 'ran';", &Event::field("Calculate", "a", ""), &doc(), &fields(), &[hostile[0].clone()], Limits::default());
|
||||
assert!(o.error.is_some());
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "ran"));
|
||||
assert!(o.doc_errors.len() == 1 && o.doc_errors[0].contains("nested too deeply"), "{:?}", o.doc_errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -805,6 +869,14 @@ mod formcalc {
|
||||
o.result.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A negative value that rounds to zero under the picture prints without a minus sign.
|
||||
#[test]
|
||||
fn format_does_not_print_negative_zero() {
|
||||
assert_eq!(calc(r#"Format("9.9", -0.04)"#), "0.0");
|
||||
assert_eq!(calc(r#"Format("99", -0.2)"#), "00");
|
||||
assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5");
|
||||
}
|
||||
|
||||
/// Assigning one long value to many fields at once stops at the run's byte budget and is
|
||||
/// charged per copy, and a reset list names each field once.
|
||||
#[test]
|
||||
@@ -990,6 +1062,9 @@ mod formcalc {
|
||||
assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51");
|
||||
assert_eq!(calc(r#"UnitValue("2in", "mm")"#), "50.8");
|
||||
assert_eq!(calc(r#"UnitType("2.5cm")"#), "cm");
|
||||
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");
|
||||
assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network"));
|
||||
@@ -1325,6 +1400,8 @@ mod session {
|
||||
use crate::xfa::*;
|
||||
use crate::xfa_session::{Script, XfaSession};
|
||||
|
||||
/// A JavaScript or FormCalc script for `activity` on `target`, against the test form with
|
||||
/// default limits and a 3 s timeout.
|
||||
fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script {
|
||||
Script {
|
||||
source: source.into(),
|
||||
@@ -1336,6 +1413,9 @@ mod session {
|
||||
}
|
||||
}
|
||||
|
||||
/// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller
|
||||
/// sends: nothing an earlier script changed or defined leaks into the next, a failing script
|
||||
/// doesn't end the session, and hundreds of scripts run on its one thread.
|
||||
#[test]
|
||||
fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() {
|
||||
let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() });
|
||||
@@ -1375,6 +1455,8 @@ mod session {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's
|
||||
/// time limit still runs and returns its result, and the session stays alive.
|
||||
#[test]
|
||||
fn a_scripts_time_starts_when_its_context_is_ready() {
|
||||
// The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is
|
||||
@@ -1395,6 +1477,9 @@ mod session {
|
||||
assert_eq!(s.dead(), None);
|
||||
}
|
||||
|
||||
/// An endless JavaScript loop past its 200 ms limit is abandoned within 2 s and leaves the
|
||||
/// session dead, so nothing more runs in it; an endless FormCalc loop is abandoned or
|
||||
/// reported as running too long.
|
||||
#[test]
|
||||
fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() {
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
@@ -1451,3 +1536,272 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() {
|
||||
assert_eq!(read.value, "undefined:4:false");
|
||||
assert!(read.changed.is_empty());
|
||||
}
|
||||
|
||||
mod scand {
|
||||
use super::*;
|
||||
use crate::xfa::{XfaDoc, XfaEvent, run_xfa};
|
||||
|
||||
/// Runs `util.scand(format, text)` for each case and checks the date it gives, written as
|
||||
/// `yyyy-mm-dd HH:MM:ss`, or `null`.
|
||||
fn assert_scand(cases: &[(&str, &str, &str)]) {
|
||||
let list: Vec<String> = cases.iter().map(|(f, t, _)| format!("['{f}', '{t}']")).collect();
|
||||
let script = format!(
|
||||
"event.value = [{}].map(function (c) {{ var d = util.scand(c[0], c[1]); \
|
||||
return d === null ? 'null' : util.printd('yyyy-mm-dd HH:MM:ss', d); }}).join('|');",
|
||||
list.join(", ")
|
||||
);
|
||||
let o = go(&script, &Event::field("Calculate", "total", ""));
|
||||
assert_eq!(o.error, None);
|
||||
let got: Vec<&str> = o.value.split('|').collect();
|
||||
assert_eq!(got.len(), cases.len());
|
||||
for ((f, t, want), got) in cases.iter().zip(got) {
|
||||
assert_eq!(got, *want, "util.scand('{f}', '{t}')");
|
||||
}
|
||||
}
|
||||
|
||||
// The cases below are another viewer's answers, observed black-box with a synthetic file.
|
||||
|
||||
/// Any separator may come between fields, whether the format has that one, another or none,
|
||||
/// but a text with a space or a letter before or after the date reads nothing.
|
||||
#[test]
|
||||
fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() {
|
||||
assert_scand(&[
|
||||
("yyyymmdd", "20261018", "2026-10-18 00:00:00"),
|
||||
("yyyymmdd", "2026-10-18", "2026-10-18 00:00:00"),
|
||||
("yyyymmdd", "2026 10 18", "2026-10-18 00:00:00"),
|
||||
("mm/dd/yyyy", "10/18/2026", "2026-10-18 00:00:00"),
|
||||
("mm/dd/yyyy", "10-18-2026", "2026-10-18 00:00:00"),
|
||||
("yyyymmdd", " 20261018 ", "null"),
|
||||
("yyyymmdd", "20261018x", "null"),
|
||||
("yyyymmdd", "x20261018", "null"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Digits typed together fill the joined fields with exactly as many digits as each field has
|
||||
/// letters, so a run of any other length reads nothing.
|
||||
#[test]
|
||||
fn scand_splits_digits_typed_together_by_the_formats_letter_counts() {
|
||||
assert_scand(&[
|
||||
("yyyymmdd", "261018", "null"),
|
||||
("yyyymmdd", "2026118", "null"),
|
||||
("yyyymmdd", "202610181", "null"),
|
||||
("yymmdd", "20261018", "null"),
|
||||
("yyyymdd", "2026118", "2026-01-18 00:00:00"),
|
||||
("yyyymmd", "2026118", "2026-11-08 00:00:00"),
|
||||
("yyyymd", "2026118", "null"),
|
||||
("yyyymd", "202618", "2026-01-08 00:00:00"),
|
||||
("yyyymd", "20261018", "null"),
|
||||
("yymd", "2618", "2026-01-08 00:00:00"),
|
||||
("yymd", "261018", "null"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A month past 12, a day past the month's end and February 29 outside a leap year are
|
||||
/// refused, while a month or day of 0 is read as 1.
|
||||
#[test]
|
||||
fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() {
|
||||
assert_scand(&[
|
||||
("yyyymmdd", "20261318", "null"),
|
||||
("yyyymmdd", "20260230", "null"),
|
||||
("yyyymmdd", "20260229", "null"),
|
||||
("yyyymmdd", "20240229", "2024-02-29 00:00:00"),
|
||||
("mm/dd/yyyy", "13/45/2026", "null"),
|
||||
("yyyymmdd", "20260018", "2026-01-18 00:00:00"),
|
||||
("yyyymmdd", "20261000", "2026-10-01 00:00:00"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A field typed by itself may have one or two digits, but a `yyyy` year needs four; `yy` reads
|
||||
/// as 1950–2049; month names may be full or three letters in any case; weekday names are read.
|
||||
#[test]
|
||||
fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() {
|
||||
assert_scand(&[
|
||||
("mm/dd/yyyy", "1/5/2026", "2026-01-05 00:00:00"),
|
||||
("m/d/yyyy", "1/5/2026", "2026-01-05 00:00:00"),
|
||||
("m/d/yyyy", "01/05/2026", "2026-01-05 00:00:00"),
|
||||
("m/d/yy", "1/5/26", "2026-01-05 00:00:00"),
|
||||
("mm/dd/yyyy", "10/18/26", "null"),
|
||||
("yyyy/mm/dd", "26/10/18", "null"),
|
||||
("yymmdd", "261018", "2026-10-18 00:00:00"),
|
||||
("yymmdd", "491018", "2049-10-18 00:00:00"),
|
||||
("yymmdd", "501018", "1950-10-18 00:00:00"),
|
||||
("mmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"),
|
||||
("mmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"),
|
||||
("mmm d, yyyy", "oct 18, 2026", "2026-10-18 00:00:00"),
|
||||
("mmmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"),
|
||||
("mmmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"),
|
||||
("dd-mmm-yyyy", "18-Oct-2026", "2026-10-18 00:00:00"),
|
||||
("dddd, mmmm d, yyyy", "Sunday, October 18, 2026", "2026-10-18 00:00:00"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A time is read along with a date, but a format with only time fields reads nothing.
|
||||
#[test]
|
||||
fn scand_reads_a_time_only_with_a_date() {
|
||||
assert_scand(&[
|
||||
("mm/dd/yyyy HH:MM:ss", "10/18/2026 21:07:05", "2026-10-18 21:07:05"),
|
||||
("HH:MM", "09:07", "null"),
|
||||
("HH:MM", "9:07", "null"),
|
||||
("h:MM tt", "9:07 pm", "null"),
|
||||
("h:MM tt", "9:07 am", "null"),
|
||||
("h:MM tt", "12:07 am", "null"),
|
||||
("h:MM tt", "9:07pm", "null"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// An empty text reads as today at noon, and a text that is not a string (a number, or none
|
||||
/// at all) reads nothing.
|
||||
#[test]
|
||||
fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() {
|
||||
let o = go(
|
||||
"var d = util.scand('yyyymmdd', ''), n = new Date(); \
|
||||
event.value = [d.toDateString() == n.toDateString(), d.getHours(), d.getMinutes(), d.getSeconds(), \
|
||||
util.scand('yyyymmdd', 20261018), util.scand(), util.scand('yyyymmdd')].join('|');",
|
||||
&Event::field("Calculate", "total", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "true|12|0|0|||");
|
||||
}
|
||||
|
||||
// Not observed: the choices made where the other viewer wasn't asked.
|
||||
|
||||
/// A year left out is this year, `h:MM tt` reads a 12-hour time and refuses hours it cannot
|
||||
/// have, and a letter the format writes must be typed there.
|
||||
#[test]
|
||||
fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() {
|
||||
assert_scand(&[
|
||||
("mm/dd/yyyy h:MM tt", "10/18/2026 9:07 pm", "2026-10-18 21:07:00"),
|
||||
("mm/dd/yyyy h:MM tt", "10/18/2026 12:07 AM", "2026-10-18 00:07:00"),
|
||||
("mm/dd/yyyy h:MM tt", "10/18/2026 13:07 pm", "null"),
|
||||
("mm/dd/yyyy HH:MM", "10/18/2026 24:00", "null"),
|
||||
("yyyy-mm-ddTHH:MM", "2026-10-18T21:07", "2026-10-18 21:07:00"),
|
||||
("yyyy-mm-ddTHH:MM", "2026-10-18X21:07", "null"),
|
||||
]);
|
||||
let o = go(
|
||||
"var d = util.scand('mm/dd', '10/18'); event.value = [d.getFullYear() == new Date().getFullYear(), d.getMonth(), d.getDate()].join('|');",
|
||||
&Event::field("Calculate", "total", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "true|9|18");
|
||||
}
|
||||
|
||||
/// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read
|
||||
/// in linear time. The limit is generous so a loaded runner passes (it also covers starting
|
||||
/// the engine and building the strings); a quadratic scan of inputs this size takes minutes.
|
||||
#[test]
|
||||
fn scand_reads_long_formats_and_texts_in_linear_time() {
|
||||
// Scripts can pass anything; native code isn't bounded by the loop limit.
|
||||
let started = std::time::Instant::now();
|
||||
let o = go(
|
||||
"var x = new Array(50001).join('x'), md = new Array(50001).join('md'), ones = new Array(100000).join('1 ') + '1'; \
|
||||
event.value = [util.scand(x + 'yyyy', x + '2026') !== null, util.scand('yyyy' + md, '2026 ' + ones) !== null].join('|');",
|
||||
&Event::field("Calculate", "total", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "true|true");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and
|
||||
/// writes the first that reads the text back as `yyyymmdd`, or reports an invalid date.
|
||||
#[test]
|
||||
fn xfa_scripts_check_typed_dates_with_util_scand() {
|
||||
// The check a form runs when a date field is left: the first of six formats that reads
|
||||
// the typed text, written back as yyyymmdd; or an invalid date. Results as another viewer gives them.
|
||||
let check = |typed: &str| {
|
||||
let script = format!(
|
||||
"var six = ['yyyymmdd', 'yymmdd', 'yyyymdd', 'yyyymmd', 'yyyymd', 'yymd'], out = 'invalid'; \
|
||||
for (var i = 0; i < six.length; i++) {{ var d = util.scand(six[i], '{typed}'); \
|
||||
if (d !== null) {{ out = util.printd('yyyymmdd', d); break; }} }} out"
|
||||
);
|
||||
let event = XfaEvent { activity: "exit".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
|
||||
run_xfa(&script, &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default())
|
||||
};
|
||||
for (typed, want) in [
|
||||
("20261018", "20261018"),
|
||||
("261018", "20261018"),
|
||||
("2026118", "20260118"),
|
||||
("202618", "20260108"),
|
||||
("2618", "20260108"),
|
||||
("2026-10-18", "20261018"),
|
||||
("20261318", "invalid"),
|
||||
("20260230", "invalid"),
|
||||
] {
|
||||
let o = check(typed);
|
||||
assert_eq!(o.error, None, "{typed}: {o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some(want), "{typed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod menu_items {
|
||||
use super::*;
|
||||
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
|
||||
|
||||
/// Every side panel has a fixed id in automation output.
|
||||
#[test]
|
||||
fn each_panel_has_a_fixed_id_for_automation() {
|
||||
let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id);
|
||||
assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]);
|
||||
}
|
||||
|
||||
/// Each side panel item, Print and SaveAs make their request, in order; an unknown item
|
||||
/// makes none and is not an error.
|
||||
#[test]
|
||||
fn exec_menu_item_shows_side_panels_prints_and_saves() {
|
||||
let o = go(
|
||||
"app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \
|
||||
app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \
|
||||
app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');",
|
||||
&Event::field("Mouse Up", "total", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(
|
||||
o.requests,
|
||||
[
|
||||
Request::ShowPanel(Panel::Attachments),
|
||||
Request::ShowPanel(Panel::Bookmarks),
|
||||
Request::ShowPanel(Panel::Pages),
|
||||
Request::ShowPanel(Panel::Signatures),
|
||||
Request::ShowPanel(Panel::Layers),
|
||||
Request::Print,
|
||||
Request::SaveAs,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `app.execMenuItem` works in XFA scripts too: a click shows the attachments panel.
|
||||
#[test]
|
||||
fn xfa_scripts_show_the_attachments_panel() {
|
||||
let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
|
||||
let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default());
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects);
|
||||
}
|
||||
}
|
||||
|
||||
/// The check XFA forms run on opening: without both globals, or with a version below 2.8, it
|
||||
/// alerts that the viewer can't show the form.
|
||||
const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \
|
||||
event.value = typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version;";
|
||||
|
||||
/// An XFA form sees `xfa_installed` (true) and `xfa_version` (3.5), so the check passes.
|
||||
#[test]
|
||||
fn an_xfa_form_sees_the_xfa_plugin_installed() {
|
||||
let xfa = DocInfo { xfa: true, ..doc() };
|
||||
let o = run(XFA_CHECK, &Event::doc("Open"), &xfa, &fields(), &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "boolean number 3.5");
|
||||
assert!(o.alerts.is_empty(), "{:?}", o.alerts);
|
||||
let o = run("event.value = String(xfa_installed);", &Event::doc("Open"), &xfa, &fields(), &[], Limits::default());
|
||||
assert_eq!(o.value, "true");
|
||||
}
|
||||
|
||||
/// A form without XFA sees `xfa_version` but no `xfa_installed`, so the check alerts.
|
||||
#[test]
|
||||
fn a_plain_form_sees_the_xfa_version_but_not_installed() {
|
||||
let o = go(XFA_CHECK, &Event::doc("Open"));
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "undefined number 3.5");
|
||||
assert_eq!(o.alerts, ["old viewer"]);
|
||||
}
|
||||
+26
-6
@@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy};
|
||||
use boa_engine::property::Attribute;
|
||||
use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
|
||||
use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, text};
|
||||
use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text};
|
||||
|
||||
/// What kind of template object a node is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -115,6 +115,8 @@ pub enum XfaEffect {
|
||||
MessageBox(String),
|
||||
/// `xfa.host.resetData()`: the named fields, or every field when empty.
|
||||
ResetData(Vec<String>),
|
||||
/// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
|
||||
ShowPanel(crate::Panel),
|
||||
Print,
|
||||
SaveAs,
|
||||
LaunchUrl(String),
|
||||
@@ -219,7 +221,7 @@ fn payload(e: &XfaEffect) -> usize {
|
||||
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(),
|
||||
XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0,
|
||||
XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,39 +1133,50 @@ fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult<JsValue> {
|
||||
|
||||
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
|
||||
|
||||
/// Records effect `e` on the host, as [`XHost::push`] does.
|
||||
fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> {
|
||||
host(ctx)?.borrow_mut().push(e);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and
|
||||
/// returns 1.
|
||||
fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = clip(text(&arg(args, 0), ctx)?);
|
||||
effect(ctx, XfaEffect::MessageBox(m))?;
|
||||
Ok(JsValue::from(1.0))
|
||||
}
|
||||
|
||||
/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when
|
||||
/// the list is empty.
|
||||
fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let list = text(&arg(args, 0), ctx)?;
|
||||
effect(ctx, XfaEffect::ResetData(reset_names(&list)))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.print(…)`: asks to print; its arguments are ignored.
|
||||
fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Print)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.beep()`: asks to beep.
|
||||
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Beep)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.gotoURL(url)`: asks to open the URL.
|
||||
fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let u = text(&arg(args, 0), ctx)?;
|
||||
effect(ctx, XfaEffect::LaunchUrl(u))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM
|
||||
/// expression resolved from the current node; an expression that names no node is passed on
|
||||
/// as written.
|
||||
fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let target = arg(args, 0);
|
||||
let som = match target.as_object() {
|
||||
@@ -1182,11 +1195,14 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVal
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name
|
||||
/// (Save opens Save As too); an unknown name does nothing.
|
||||
fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let item = text(&arg(args, 0), ctx)?;
|
||||
match item.as_str() {
|
||||
"SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?,
|
||||
"Print" => effect(ctx, XfaEffect::Print)?,
|
||||
match crate::menu_item(&item) {
|
||||
Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?,
|
||||
Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?,
|
||||
Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
@@ -1233,6 +1249,9 @@ fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult<JsValue> {
|
||||
resolver(ctx, current, many)
|
||||
}
|
||||
|
||||
/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`,
|
||||
/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`,
|
||||
/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager.
|
||||
fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> {
|
||||
let rw = Attribute::CONFIGURABLE;
|
||||
let form = node_object(ctx, 0)?;
|
||||
@@ -1343,6 +1362,7 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) ->
|
||||
.function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2)
|
||||
.build();
|
||||
ctx.register_global_property(js_string!("util"), util, rw)?;
|
||||
// Unqualified names: the current node's children, its siblings, then each ancestor's
|
||||
@@ -1416,7 +1436,7 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) ->
|
||||
"DataView",
|
||||
"Intl",
|
||||
];
|
||||
let mut defined: Vec<String> = RESERVED.iter().map(|s| s.to_string()).collect();
|
||||
let mut defined: Vec<String> = RESERVED.iter().map(ToString::to_string).collect();
|
||||
for scope in scopes {
|
||||
let kids: Vec<(usize, String, bool)> = {
|
||||
let h = host(ctx)?.borrow();
|
||||
|
||||
@@ -522,6 +522,15 @@ pub fn list(doc: &Document) -> Listing {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Most viewports [`set_scale`] gives a page, and most [`scale_at`] reads.
|
||||
const MAX_VIEWPORTS: usize = 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Numbers read from viewport `/BBox` arrays.
|
||||
pub(crate) static VIEWPORT_NUMBERS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9).
|
||||
/// Outside every viewport the page measures true size in inches.
|
||||
pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result<Scale> {
|
||||
@@ -535,12 +544,17 @@ pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64,
|
||||
if let Some(o) = p.dict.get(b"VP") {
|
||||
let o = doc.resolve(o);
|
||||
let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?;
|
||||
for viewport in arr.iter().rev() {
|
||||
// The last viewports win; a page holds at most MAX_VIEWPORTS of its own.
|
||||
for viewport in arr.iter().rev().take(MAX_VIEWPORTS) {
|
||||
let o = doc.resolve(viewport);
|
||||
let Some(d) = o.as_dict() else { continue };
|
||||
if let Some(bbox) = d.get(b"BBox") {
|
||||
let o = doc.resolve(bbox);
|
||||
let b: Option<Vec<f64>> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect());
|
||||
let b: Option<Vec<f64>> = o.as_array().filter(|a| a.len() == 4).and_then(|a| {
|
||||
#[cfg(test)]
|
||||
VIEWPORT_NUMBERS_READ.with(|n| n.set(n.get() + a.len()));
|
||||
a.iter().map(|v| doc.resolve(v).as_f64()).collect()
|
||||
});
|
||||
if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite()))
|
||||
&& at[0] >= b[0].min(b[2])
|
||||
&& at[0] <= b[0].max(b[2])
|
||||
@@ -566,7 +580,7 @@ pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &s
|
||||
Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
if list.len() >= 1024 {
|
||||
if list.len() >= MAX_VIEWPORTS {
|
||||
return Err(invalid("page has too many measurement viewports"));
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
|
||||
@@ -256,6 +256,8 @@ impl Walker<'_> {
|
||||
let mut current = None;
|
||||
let mut closures = Vec::new();
|
||||
let mut path = Geometry::default();
|
||||
// Read on the first `Do` and kept: resolving a direct dictionary copies it.
|
||||
let mut xobjects = None;
|
||||
let point = |m: Matrix, x: f64, y: f64| {
|
||||
let (x, y) = m.apply(x, y);
|
||||
[x, y]
|
||||
@@ -390,7 +392,11 @@ impl Walker<'_> {
|
||||
}
|
||||
"Do" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o));
|
||||
let xobjects = xobjects.get_or_insert_with(|| {
|
||||
#[cfg(test)]
|
||||
XOBJECT_DICT_READS.with(|n| n.set(n.get() + 1));
|
||||
resources.get(b"XObject").map(|o| self.doc.resolve(o))
|
||||
});
|
||||
if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) {
|
||||
let r = o.as_ref();
|
||||
if r.is_some_and(|r| self.seen.contains(&r)) {
|
||||
@@ -426,6 +432,12 @@ impl Walker<'_> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Times a content stream's `/XObject` resources were read.
|
||||
pub(crate) static XOBJECT_DICT_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub fn geometry(doc: &Document, page_index: usize) -> Result<Geometry> {
|
||||
let p = crate::page(doc, page_index)?;
|
||||
let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o));
|
||||
|
||||
@@ -403,6 +403,31 @@ fn a_scale_ratio_converts_its_page_side_to_points() {
|
||||
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
|
||||
}
|
||||
}
|
||||
/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct
|
||||
/// dictionary) for every `Do`.
|
||||
#[test]
|
||||
fn xobject_resources_are_read_once_per_stream() {
|
||||
let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]);
|
||||
snap::XOBJECT_DICT_READS.with(|c| c.set(0));
|
||||
let paths = snap::geometry(&d, 0).unwrap();
|
||||
assert!(paths.segments.is_empty());
|
||||
let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get);
|
||||
assert!(reads <= 1, "{reads} reads of the page's XObject resources");
|
||||
}
|
||||
/// Finding the scale under a point reads only well-formed viewport boxes, and only as many
|
||||
/// viewports as a page can be given, however many a file lists or however long their boxes.
|
||||
#[test]
|
||||
fn scale_lookup_reads_viewports_within_bounds() {
|
||||
let vp = vec!["5 0 R"; 5_000].join(" ");
|
||||
let long_box = vec!["0"; 2_000].join(" ");
|
||||
let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]);
|
||||
VIEWPORT_NUMBERS_READ.with(|c| c.set(0));
|
||||
let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap();
|
||||
assert_eq!(scale.unit, "in");
|
||||
let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get);
|
||||
assert!(read <= 4 * 1024, "{read} viewport numbers read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
|
||||
let mut d = fixture("", "", &[]);
|
||||
|
||||
@@ -12,12 +12,15 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-filters = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-annot = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
# JPEG decoding (zune-jpeg) and encoding, and bicubic resampling; already in the tree.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
# The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0).
|
||||
zune-jpeg = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-render = { workspace = true }
|
||||
@@ -21,19 +21,37 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary
|
||||
policy, so trailing comments, split operands and graphics state keep their meaning after merging.
|
||||
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
|
||||
through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be
|
||||
through form XObjects, up to 12 deep; the smallest over all uses).
|
||||
Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches.
|
||||
If a use can't be
|
||||
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
|
||||
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image.
|
||||
An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces,
|
||||
image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT.
|
||||
image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. A JPEG is read with
|
||||
its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one
|
||||
stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left
|
||||
alone, since readers differ on which wins.
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
|
||||
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
|
||||
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
|
||||
bytes). Streams with `/DecodeParms`, images and object streams are left alone.
|
||||
bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed
|
||||
streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In
|
||||
streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without
|
||||
`EarlyChange`, filters applied before the LZW one go with it and those after it keep their
|
||||
bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images.
|
||||
|
||||
The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories,
|
||||
then this, then merges identical objects and writes a full, compressed save
|
||||
(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a
|
||||
progress bar). JPEG decoding and
|
||||
encoding and resampling come from the `image` crate.
|
||||
progress bar). JPEG decoding comes from `zune-jpeg` (the decoder behind `image` and the
|
||||
renderer), JPEG encoding and resampling from the `image` crate.
|
||||
|
||||
## Target PDF version: partial compatibility
|
||||
|
||||
The target selects the header version, clears the catalog `/Version`, controls object
|
||||
streams (disabled for PDF 1.4), and checks encryption requirements. It does **not**
|
||||
detect or convert content newer than the target: optional content, JPEG 2000 images,
|
||||
16-bit images, OpenType fonts and PDF 2.0 features can remain. A PDF 1.4 header
|
||||
therefore does not guarantee compatibility with PDF 1.4 readers or validators.
|
||||
`optimize.compatibility-level` remains partial until content compatibility is checked.
|
||||
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user