mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 17:31:35 +00:00
Merge upstream/main into fix/min-window-width
Upstream dropped the Read mode tab and added its own scrolling rail (#3282); keep upstream's rail, keep the PR's narrow-window mode menu, and move the min-window tests into the tests/it binary (rail test dropped, covered upstream). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
2ec60450fd
598 files changed
+114148
-10269
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
+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@v2
|
||||
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
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# 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:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -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
|
||||
|
||||
+45
-30
@@ -2,32 +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 (272)
|
||||
## 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | German 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | Arabic 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 | 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 | Lithuanian 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 |
|
||||
@@ -223,6 +230,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
|
||||
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
|
||||
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
@@ -267,21 +275,29 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) |
|
||||
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) |
|
||||
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make |
|
||||
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make |
|
||||
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
|
||||
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
|
||||
| `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/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `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 | 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 | 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; 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; 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 (6)
|
||||
## Compiled in through dependencies (5)
|
||||
|
||||
| Crate | File | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|---|
|
||||
@@ -289,7 +305,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `hayro-cmap` 0.1.0 | `assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) (non-visual data, AGENTS.md §1.1) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
|
||||
## Downloaded at build time (22)
|
||||
@@ -327,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 |
|
||||
+184
-53
@@ -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"
|
||||
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 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
|
||||
|
||||
# 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,211 +26,260 @@ sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
|
||||
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"
|
||||
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 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
|
||||
|
||||
[[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"
|
||||
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 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
|
||||
|
||||
[[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"
|
||||
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 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
|
||||
|
||||
[[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"
|
||||
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 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
|
||||
|
||||
[[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"
|
||||
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 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
|
||||
|
||||
[[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"
|
||||
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 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
|
||||
|
||||
[[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"
|
||||
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 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
|
||||
|
||||
[[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; 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"
|
||||
|
||||
[[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"
|
||||
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 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
|
||||
|
||||
[[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"
|
||||
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 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
|
||||
|
||||
[[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"
|
||||
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 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
|
||||
|
||||
[[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"
|
||||
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 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
|
||||
|
||||
[[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"
|
||||
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 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
|
||||
|
||||
[[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"
|
||||
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 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
|
||||
|
||||
[[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"
|
||||
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 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
|
||||
|
||||
[[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"
|
||||
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 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
|
||||
|
||||
[[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"
|
||||
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 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
|
||||
|
||||
[[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"
|
||||
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 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
|
||||
|
||||
[[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; 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"
|
||||
|
||||
[[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"
|
||||
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 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
|
||||
|
||||
[[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"
|
||||
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 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
|
||||
|
||||
[[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; 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"
|
||||
[[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; 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"
|
||||
[[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; 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"
|
||||
|
||||
[[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; 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"
|
||||
|
||||
|
||||
[[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; 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"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -2425,14 +2475,13 @@ kind = "font"
|
||||
usage = "egui fallback fonts (eframe feature default_fonts)"
|
||||
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
|
||||
|
||||
[[bundled]]
|
||||
crate = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
path = "assets/cmaps.brotli"
|
||||
[[asset]]
|
||||
path = "vendor/hayro-cmap/assets/cmaps.brotli"
|
||||
title = "Adobe CMap resources (predefined CJK CMaps)"
|
||||
author = "Adobe"
|
||||
source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap)"
|
||||
source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/)"
|
||||
licence = "BSD-3-Clause"
|
||||
licence_file = "vendor/hayro-cmap/assets/LICENSE.txt"
|
||||
kind = "data"
|
||||
adobe_data = true
|
||||
usage = "Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1"
|
||||
@@ -3203,7 +3252,7 @@ source = "Contributor-original: hand-written PDF objects, no third-party content
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "document"
|
||||
usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)"
|
||||
usage = "Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs)"
|
||||
sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040"
|
||||
|
||||
[[asset]]
|
||||
@@ -3236,7 +3285,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "document"
|
||||
usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make"
|
||||
usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make"
|
||||
sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c"
|
||||
|
||||
[[asset]]
|
||||
@@ -3319,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"
|
||||
@@ -3329,7 +3378,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "document"
|
||||
usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
|
||||
usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
|
||||
sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b"
|
||||
|
||||
[[asset]]
|
||||
@@ -3347,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"
|
||||
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 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
|
||||
|
||||
[[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"
|
||||
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 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
|
||||
|
||||
[[asset]]
|
||||
|
||||
@@ -3376,3 +3423,87 @@ licence_file = "vendor/vello_common/LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
|
||||
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
|
||||
|
||||
[[asset]]
|
||||
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; 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"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/superscript.svg"
|
||||
title = "Lucide icon \"superscript\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/subscript.svg"
|
||||
title = "Lucide icon \"subscript\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/pilcrow.svg"
|
||||
title = "Lucide icon \"pilcrow\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/move-horizontal.svg"
|
||||
title = "Lucide icon \"move-horizontal\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
|
||||
|
||||
[[asset]]
|
||||
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; 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"
|
||||
|
||||
[[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; 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"
|
||||
|
||||
[[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; 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"
|
||||
@@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- No `unsafe` (`unsafe_code = "forbid"`).
|
||||
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
|
||||
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
|
||||
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
|
||||
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
|
||||
@@ -38,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
|
||||
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
|
||||
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
|
||||
- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search "<terms>"` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor <commit> origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
|
||||
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
|
||||
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
|
||||
|
||||
|
||||
Generated
+93
-55
@@ -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",
|
||||
@@ -1887,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2549,8 +2549,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
@@ -2593,8 +2591,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-postscript"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
@@ -3938,8 +3934,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3963,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3975,7 +3972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -3986,7 +3983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3997,7 +3994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -4011,6 +4008,7 @@ dependencies = [
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
@@ -4018,7 +4016,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4028,6 +4026,7 @@ dependencies = [
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-preflight",
|
||||
"pdfcraft-render",
|
||||
"png 0.18.1",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
@@ -4035,19 +4034,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
"pdfcraft-crypt",
|
||||
"pdfcraft-filters",
|
||||
@@ -4060,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4076,7 +4077,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4088,7 +4089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4103,7 +4104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4132,6 +4133,7 @@ dependencies = [
|
||||
"pdfcraft-redact",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-sign",
|
||||
"pdfcraft-testkit",
|
||||
"pdfcraft-xfa",
|
||||
"pdfcraft-xfdf",
|
||||
"png 0.18.1",
|
||||
@@ -4143,14 +4145,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4160,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4170,30 +4172,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4206,14 +4210,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4223,24 +4227,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4251,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4261,27 +4267,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4297,7 +4306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4316,7 +4325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4324,6 +4333,7 @@ dependencies = [
|
||||
"bp256",
|
||||
"bp384",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"crypto-bigint",
|
||||
"des",
|
||||
"ecdsa",
|
||||
@@ -4337,6 +4347,7 @@ dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"rc2",
|
||||
"ripemd",
|
||||
"rsa",
|
||||
@@ -4350,15 +4361,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"eframe",
|
||||
@@ -4380,6 +4393,7 @@ dependencies = [
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"plist",
|
||||
"png 0.18.1",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
@@ -4394,11 +4408,12 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"winsafe",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4410,7 +4425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4424,7 +4439,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
@@ -4651,6 +4666,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
@@ -4878,6 +4906,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
@@ -4932,7 +4969,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5482,7 +5519,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5552,7 +5589,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5563,9 +5600,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -6429,7 +6466,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7250,7 +7287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -7304,9 +7341,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -7512,7 +7549,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8032,6 +8069,7 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"lopdf",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-oracle",
|
||||
|
||||
+44
-40
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.9.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.9.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.9.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.9.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.9.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.9.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.9.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.9.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.13.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.13.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.13.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.13.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.13.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.13.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.13.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.13.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.13.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.13.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.13.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.13.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.13.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.13.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.13.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.13.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.13.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.13.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.13.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.13.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.13.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
@@ -68,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
kurbo = "0.13"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
|
||||
# testing
|
||||
proptest = "1"
|
||||
@@ -87,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]
|
||||
@@ -112,6 +114,8 @@ codegen-units = 1
|
||||
hayro-interpret = { path = "vendor/hayro-interpret" }
|
||||
hayro = { path = "vendor/hayro" }
|
||||
hayro-syntax = { path = "vendor/hayro-syntax" }
|
||||
hayro-cmap = { path = "vendor/hayro-cmap" }
|
||||
hayro-postscript = { path = "vendor/hayro-postscript" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
winit = { path = "vendor/winit" }
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" width="128" height="128" alt="PdfCraft icon: an engraved lion on a teal tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">PdfCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -17,10 +21,13 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563">
|
||||
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a">
|
||||
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563?style=flat-square">
|
||||
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a?style=flat-square">
|
||||
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_pdfcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/pdfcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -137,6 +144,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re
|
||||
|
||||
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
|
||||
|
||||
In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/pdfcraft-find.png" alt="The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading"></td>
|
||||
|
||||
+171
-7
@@ -2,6 +2,24 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
|
||||
@@ -15,10 +33,12 @@
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-16 · **Change:** minor (M14: Czech catalog completed and reviewed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -40,6 +60,14 @@
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
|
||||
|
||||
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
|
||||
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
|
||||
@@ -131,18 +159,18 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
|
||||
|
||||
## Languages
|
||||
|
||||
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current
|
||||
union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
|
||||
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
|
||||
Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | `en` | 100% | full |
|
||||
| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
|
||||
| Spanish | `es` | 95.5% | partial |
|
||||
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 100% | partial (layout not mirrored) |
|
||||
| French | `fr` | 99.3% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 99.4% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
|
||||
| Indonesian | `id` | 0% | none |
|
||||
| Japanese | `ja` | 99.1% | partial |
|
||||
| German | `de` | 99.3% | partial |
|
||||
@@ -150,8 +178,8 @@ union). Detail and script notes: [localization-parity.md](docs/localization-pari
|
||||
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
|
||||
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs**
|
||||
(23 interface languages including English).
|
||||
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
|
||||
(24 interface languages including English).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -171,9 +199,92 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
|
||||
- **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.
|
||||
|
||||
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
|
||||
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
|
||||
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
|
||||
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
|
||||
- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
|
||||
- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
|
||||
|
||||
- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows.
|
||||
|
||||
- **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.
|
||||
- **2026-10-20 (M2, PR #3348 review):** Tracking samples retain short repeated punctuation (up to four glyphs), keeping tracked ellipses intact and searchable while longer TOC leaders still preserve title word spaces. Synthetic regressions cover isolated and surrounded ellipses. Estimates unchanged.
|
||||
- **2026-10-20 (M2, #3601):** A missing spacing glyph in an OpenType substitute uses its ordinary-space glyph. Synthetic Japanese CID renderer tests check blank spaces, `/W` and `/DW` placement, and unchanged Unicode extraction. Font selection and embedded fonts are unchanged. Estimates unchanged.
|
||||
- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged.
|
||||
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
|
||||
- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
|
||||
|
||||
- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508).
|
||||
- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged.
|
||||
- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change.
|
||||
- **2026-10-20 (M6, #3502):** Form detection preserves comments across content streams and does not add fields over rectangles hidden by those comments. Synthetic tests cover visible controls, graphics state, and saved field inventories. Estimates unchanged.
|
||||
- **2026-10-20 (M6, #2273 / PR #2279):** Rebased the field-highlight default and Appearance preference onto main, retaining secured-form parity evidence and updating secured AcroForm/XFA toggle expectations. Build and test validation delegated to GitHub CI. Estimates unchanged.
|
||||
- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged.
|
||||
- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged.
|
||||
|
||||
- **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.
|
||||
|
||||
- **2026-10-18 (M3, PR #3101 review):** Gap changes in Organize Pages preserve document scroll position; menu and control changes apply to both comparison panes without changing unrelated tabs. Three synthetic UI regressions were observed failing before the fixes. Readiness estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M3, PR #3101):** Optional gapless continuous page display with persisted defaults and UI control support; both new labels translated in all 22 catalogs. Rebased onto current main, preserving navigation and rendering fixes. Readiness estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
|
||||
- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
|
||||
- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
|
||||
- **2026-10-18 (M2, #3086):** Text extraction estimates letter tracking without repeated punctuation gaps so dotted TOC leaders do not swallow word spaces. Synthetic PDF regressions cover leader lengths, phrase search and selection; strongly tracked text stays intact. Estimates unchanged.
|
||||
|
||||
- **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.
|
||||
- **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged.
|
||||
- **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged.
|
||||
- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged.
|
||||
- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged.
|
||||
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
|
||||
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
|
||||
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
|
||||
@@ -181,6 +292,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
|
||||
- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
|
||||
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
|
||||
- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
|
||||
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
|
||||
- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
|
||||
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
|
||||
@@ -188,24 +300,40 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
|
||||
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
|
||||
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
|
||||
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
|
||||
- **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 the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
|
||||
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
|
||||
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
|
||||
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
|
||||
- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. 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. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
|
||||
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
|
||||
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
|
||||
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
|
||||
- **2026-10-15 (M14, Norwegian Bokmål localization):** Added Norwegian Bokmål (`nb`) as an interface and date language: a catalog covering every key the other catalogs have, Norwegian key names, month and weekday names for Fill & Sign dates, and `no`/`nn` locales resolving to it until a Nynorsk catalog exists. Written from the meaning of the English labels; terminology reviewed by a native-speaking contributor, not yet every line. Estimates unchanged.
|
||||
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
|
||||
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
|
||||
- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged.
|
||||
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
|
||||
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
|
||||
- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
|
||||
- **2026-10-15 (G14, Romanian localization):** Added Romanian (`ro`, Română) as an interface and date language with a 2,673-entry catalog covering every bundled key, and a CLDR plural rule (`plural_ro`: one / few / other, "20 de pagini"). Locale tags `ro` / `ro-RO` / `ro_RO.UTF-8` / `ro-MD` resolve, so Auto picks it on Romanian Windows. Coverage and plural tests added. Status `partial` like the other catalogs (no translated help).
|
||||
- **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.
|
||||
@@ -246,6 +374,16 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
|
||||
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
|
||||
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
|
||||
- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned.
|
||||
- **2026-10-14 (M10, #1818):** Properties… reads a CUPS printer's driver options off the GUI thread, the dialog saying so meanwhile, and `lpoptions` is given ten seconds before it is killed and the PPD's defaults stand; a read that dies leaves the panel with nothing rather than a frozen window. The bounded wait is tested with a stand-in command that never answers.
|
||||
- **2026-10-14 (M10 #2156 candidate):** Print paper selection survives Cancel and settings restoration; canonical names avoid preset-index dependence. Other print preferences and estimates unchanged.
|
||||
|
||||
- **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.
|
||||
@@ -538,12 +676,26 @@ 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. |
|
||||
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
|
||||
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
|
||||
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
|
||||
@@ -575,3 +727,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.
|
||||
@@ -23,6 +23,10 @@ pdfcraft-automation = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine.
|
||||
png = "0.18"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
|
||||
# Already in Cargo.lock through the GUI crates.
|
||||
|
||||
+173
-48
@@ -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,14 +186,19 @@ 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}"))?;
|
||||
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
|
||||
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
|
||||
// reports them; the page geometry is read only when there is something to place.
|
||||
let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
|
||||
let info = if findings.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?)
|
||||
};
|
||||
let items: Vec<serde_json::Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
@@ -203,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") {
|
||||
@@ -225,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;
|
||||
}
|
||||
@@ -273,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
|
||||
}
|
||||
@@ -296,10 +355,12 @@ 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");
|
||||
let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let pdf_pages = info.pages.len();
|
||||
let acroform_fields = info.fields.len();
|
||||
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
|
||||
@@ -347,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();
|
||||
@@ -366,6 +427,9 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
let password = flag(args, "--password");
|
||||
let mut bytes = read(path)?;
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
// Worked out from the file as read: `bytes` may become the session's display copy below.
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
|
||||
// permissions password, a document that doesn't allow copying gives no text.
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
@@ -378,12 +442,12 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
bytes = doc.display.clone();
|
||||
}
|
||||
}
|
||||
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
|
||||
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() });
|
||||
// A document the renderer could not open (wrong or missing password, unparseable file) has
|
||||
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
|
||||
// `inspect` why instead, so a protected file fails the same way `info` does.
|
||||
if r.page_count() == 0 {
|
||||
let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?;
|
||||
let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?;
|
||||
if !info.pages.is_empty() {
|
||||
return Err("text: the document could not be parsed".into());
|
||||
}
|
||||
@@ -472,23 +536,43 @@ 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),
|
||||
"--rotate" => {
|
||||
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
|
||||
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? });
|
||||
let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?;
|
||||
// The same rule `rotate_pages` enforces, checked here so a bad angle is named
|
||||
// with its option before any file is opened.
|
||||
if degrees % 90 != 0 {
|
||||
return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into());
|
||||
}
|
||||
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees });
|
||||
}
|
||||
"--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
|
||||
"--move" => {
|
||||
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
|
||||
let to: usize = to.parse().map_err(|_| "bad target")?;
|
||||
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
|
||||
let target: usize = to
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|target| *target > 0)
|
||||
.ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?;
|
||||
edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 });
|
||||
}
|
||||
"--insert-blank" => {
|
||||
let at: usize = value.parse().map_err(|_| "bad position")?;
|
||||
edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
|
||||
let position: usize = value
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|position| *position > 0)
|
||||
.ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?;
|
||||
edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 });
|
||||
}
|
||||
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
|
||||
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
|
||||
@@ -545,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());
|
||||
}
|
||||
@@ -556,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();
|
||||
@@ -568,9 +652,18 @@ 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) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
|
||||
(Some(n), None) => {
|
||||
// A count the splitter cannot hold is refused here, before the file is opened,
|
||||
// rather than silently clamped to 1 further down.
|
||||
let count = n
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|count| *count > 0)
|
||||
.ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?;
|
||||
SplitBy::PageCount(count)
|
||||
}
|
||||
(None, Some(list)) => SplitBy::Before(page_list(list)?),
|
||||
_ => return Err("split: give either --every N or --before PAGES".into()),
|
||||
};
|
||||
@@ -590,15 +683,28 @@ fn split(args: &[String]) -> Result<(), CliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value
|
||||
/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own
|
||||
/// internal message (and in `check`, passed on as text, they would turn every file into a
|
||||
/// child that printed nothing), so the CLI names the option and the value instead.
|
||||
fn parse_dpi(value: &str) -> Result<f32, String> {
|
||||
value
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
.filter(|dpi| dpi.is_finite() && *dpi > 0.0)
|
||||
.ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}"))
|
||||
}
|
||||
|
||||
fn render(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("render", args, RENDER_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("render: missing file")?;
|
||||
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
|
||||
let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
|
||||
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
|
||||
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),
|
||||
@@ -609,14 +715,18 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
// must agree with it (and with independent viewers), while keeping the file it reads intact.
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
|
||||
// copy take either.
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
|
||||
session.get(id).ok_or("render: the document could not be opened")?.display.clone()
|
||||
} else {
|
||||
pdfcraft_engine::display_bytes_for(bytes, password)
|
||||
};
|
||||
let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
|
||||
let mut r =
|
||||
PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() });
|
||||
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
|
||||
if let Some(e) = p.error {
|
||||
return Err(e.into());
|
||||
@@ -638,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(())
|
||||
@@ -646,8 +760,8 @@ 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 dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
|
||||
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)?;
|
||||
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
|
||||
@@ -729,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);
|
||||
}
|
||||
}
|
||||
@@ -739,12 +853,23 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
|
||||
|
||||
fn check(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("check", args, CHECK_OPTIONS)?;
|
||||
let files = collect(&positional(args));
|
||||
// Every option is checked before the inputs are scanned, so a bad value is one message,
|
||||
// not one problem per file: a zero timeout used to make every file time out, and a bad
|
||||
// dpi used to reach each child, which printed nothing and was reported as a crash.
|
||||
let timeout_value = flag(args, "--timeout").unwrap_or("20");
|
||||
let timeout = Duration::from_secs(
|
||||
timeout_value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|secs| *secs > 0)
|
||||
.ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?,
|
||||
);
|
||||
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
|
||||
parse_dpi(&dpi)?;
|
||||
let files = collect(&positional(args, CHECK_OPTIONS));
|
||||
if files.is_empty() {
|
||||
return Err("check: no PDF files found".into());
|
||||
}
|
||||
let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
|
||||
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let mut results = Vec::new();
|
||||
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
@@ -924,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);
|
||||
@@ -992,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();
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,5 @@
|
||||
//! The robustness harness visits each directory once, including symlink aliases and cycles.
|
||||
//! Its `--timeout` and `--dpi` options are checked before anything is scanned.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() {
|
||||
assert_eq!(results[0]["status"], "ok");
|
||||
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to
|
||||
/// make every file time out at once, and a dpi that is not a positive finite number used to
|
||||
/// reach each child, which printed nothing and was reported as a crash per file.
|
||||
#[test]
|
||||
fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
|
||||
let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n");
|
||||
for value in ["abc", "0", "-3", "NaN"] {
|
||||
let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "--dpi {value}");
|
||||
assert_eq!(
|
||||
out.stderr,
|
||||
format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(),
|
||||
"--dpi {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,9 @@ mod one_based_arguments {
|
||||
for destination in ["output.pam", "new.pam", "input.pdf"] {
|
||||
std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
|
||||
for (command, option, value, diagnostic) in [
|
||||
("render", "--page", "0", "--page must be at least 1"),
|
||||
("edit", "--move", "1:0", "--move target must be at least 1"),
|
||||
("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
|
||||
("render", "--page", "0", "bad --page: expected a positive page number"),
|
||||
("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
|
||||
("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
|
||||
] {
|
||||
let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
|
||||
@@ -194,6 +194,33 @@ mod one_based_arguments {
|
||||
}
|
||||
}
|
||||
|
||||
/// A value the option cannot hold names the option and the value, and fails before the input
|
||||
/// file is opened: a missing input still gets the option's own message.
|
||||
#[test]
|
||||
fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() {
|
||||
let dir = tmp("values", "");
|
||||
for (option, value, diagnostic) in [
|
||||
("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""),
|
||||
("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"),
|
||||
("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"),
|
||||
("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""),
|
||||
("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
|
||||
("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""),
|
||||
("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
|
||||
] {
|
||||
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{option} {value}");
|
||||
assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
|
||||
assert!(out.stdout.is_empty(), "{option} {value}");
|
||||
assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written");
|
||||
}
|
||||
// A negative multiple of 90 is still a rotation the engine accepts: the refusal above is
|
||||
// the multiple-of-90 rule, not a sign problem (the missing input is the next error).
|
||||
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
/// A destination past the end must fail, not silently become an append (#1330).
|
||||
#[test]
|
||||
fn a_destination_past_the_end_fails_and_writes_nothing() {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,24 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod audit;
|
||||
mod automation_roots;
|
||||
mod batch;
|
||||
mod check;
|
||||
mod edit;
|
||||
mod extract;
|
||||
mod help;
|
||||
mod info_standards;
|
||||
mod mcp_compact;
|
||||
mod options;
|
||||
mod owner_password;
|
||||
mod render;
|
||||
mod root_arguments;
|
||||
mod run_report;
|
||||
mod run_root;
|
||||
mod split;
|
||||
mod stderr;
|
||||
mod stdout;
|
||||
mod text;
|
||||
mod ui_control;
|
||||
mod version_resource;
|
||||
mod xfa;
|
||||
File renamed without changes.
@@ -0,0 +1,115 @@
|
||||
//! Every command rejects misspelled long options before opening or writing a file.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
#[test]
|
||||
fn non_edit_commands_reject_unknown_options_before_io() {
|
||||
for args in [
|
||||
vec!["info", "missing.pdf", "--passwrod", "secret"],
|
||||
vec!["text", "missing.pdf", "--pgae", "1"],
|
||||
vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"],
|
||||
vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"],
|
||||
vec!["split", "missing.pdf", "--ever", "2"],
|
||||
vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"],
|
||||
vec!["check-one", "missing.pdf", "--edti"],
|
||||
vec!["check", "missing.pdf", "--timout", "2"],
|
||||
vec!["audit", "missing.pdf", "--passwrod", "x"],
|
||||
vec!["audit", "missing.pdf", "--nonsense", "1"],
|
||||
vec!["run", "doc_open", "--roto", "/tmp"],
|
||||
vec!["ui", "--contro", "control.json", "state"],
|
||||
] {
|
||||
let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option");
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open`
|
||||
//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so
|
||||
//! the CLI must hand it the user password that owner authentication recovers.
|
||||
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Algorithm, NewEncryption};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`.
|
||||
fn fixture(content: &str) -> Vec<u8> {
|
||||
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||||
let objs: [&[u8]; 5] = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
|
||||
stream.as_bytes(),
|
||||
];
|
||||
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", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
|
||||
|
||||
/// `fixture` protected with user password "user" and owner password "owner", allowing nothing,
|
||||
/// so copying its text needs the owner password.
|
||||
fn protected(algorithm: Algorithm) -> Vec<u8> {
|
||||
protected_with(algorithm, PAGE)
|
||||
}
|
||||
|
||||
fn protected_with(algorithm: Algorithm, content: &str) -> Vec<u8> {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: 0,
|
||||
encrypt_metadata: true,
|
||||
seed: [3; 32],
|
||||
})
|
||||
.unwrap();
|
||||
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name)
|
||||
}
|
||||
|
||||
const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
|
||||
|
||||
#[test]
|
||||
fn info_accepts_the_owner_password() {
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("info-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected(alg)).unwrap();
|
||||
for pw in ["owner", "user"] {
|
||||
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
|
||||
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}");
|
||||
assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}");
|
||||
}
|
||||
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap();
|
||||
assert!(!out.status.success(), "{alg:?}: a wrong password still fails");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_accepts_the_owner_password() {
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("text-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected(alg)).unwrap();
|
||||
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{alg:?}: {stderr}");
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}");
|
||||
// The user password opens it, but the permissions still forbid copying.
|
||||
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap();
|
||||
assert!(!out.status.success(), "{alg:?}: copying needs the owner password");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn:
|
||||
/// a blank page of the right size fails too.
|
||||
fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) {
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut buf).unwrap();
|
||||
assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}");
|
||||
// A rendered page is opaque, so `render` writes it as 8-bit RGB (`pdfcraft_engine::export::encode_png`).
|
||||
assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgb, png::BitDepth::Eight), "{what}");
|
||||
// Pixels with every colour channel below half intensity: ink, not white paper.
|
||||
let inked = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().filter(|px| px.iter().all(|c| *c < 128)).count();
|
||||
assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_accepts_the_owner_password() {
|
||||
const DPI: u32 = 144;
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("render-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected(alg)).unwrap();
|
||||
for pw in ["owner", "user"] {
|
||||
let png = tmp(&format!("render-{alg:?}-{pw}.png"));
|
||||
let _ = std::fs::remove_file(&png);
|
||||
let out = Command::new(BIN)
|
||||
.args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()])
|
||||
.args(["--password", pw, "--out", png.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
|
||||
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_places_findings_with_the_owner_password() {
|
||||
// A black box over the text: a faux redaction, so `audit` reads the page geometry too.
|
||||
let covered = format!("{PAGE} 0 g 10 140 120 40 re f");
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("audit-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected_with(alg, &covered)).unwrap();
|
||||
let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}");
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}"));
|
||||
assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}");
|
||||
// Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom.
|
||||
assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}");
|
||||
}
|
||||
}
|
||||
@@ -88,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() {
|
||||
assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive
|
||||
/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but
|
||||
/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead
|
||||
/// of reaching the renderer as a scale it refuses with its own internal message.
|
||||
#[test]
|
||||
fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() {
|
||||
for (option, value, diagnostic) in [
|
||||
("--page", "abc", "bad --page: expected a positive page number"),
|
||||
("--page", "0", "bad --page: expected a positive page number"),
|
||||
("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""),
|
||||
("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""),
|
||||
("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""),
|
||||
("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""),
|
||||
("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""),
|
||||
] {
|
||||
let out = tmp("refuse", "out.png");
|
||||
// `missing.pdf` is never read: the option's own message is the error.
|
||||
let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap();
|
||||
assert_eq!(r.status.code(), Some(1), "{option} {value}");
|
||||
assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
|
||||
assert!(!out.exists(), "{option} {value}: no output may be written");
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,5 @@
|
||||
//! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't.
|
||||
//! `--every` must be a positive whole number of pages, checked before the file is opened.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
@@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() {
|
||||
assert_eq!(std::fs::read(¬_a_dir).unwrap(), b"in the way");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to
|
||||
/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone.
|
||||
#[test]
|
||||
fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() {
|
||||
let dir = workdir("every");
|
||||
for value in ["0", "abc"] {
|
||||
let out_dir = dir.join("parts");
|
||||
let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "--every {value}");
|
||||
assert_eq!(
|
||||
out.stderr,
|
||||
format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(),
|
||||
"--every {value}"
|
||||
);
|
||||
assert!(!out_dir.exists(), "--every {value}: nothing may be written");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,29 +0,0 @@
|
||||
//! Every command rejects misspelled long options before opening or writing a file.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
#[test]
|
||||
fn non_edit_commands_reject_unknown_options_before_io() {
|
||||
for args in [
|
||||
vec!["info", "missing.pdf", "--passwrod", "secret"],
|
||||
vec!["text", "missing.pdf", "--pgae", "1"],
|
||||
vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"],
|
||||
vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"],
|
||||
vec!["split", "missing.pdf", "--ever", "2"],
|
||||
vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"],
|
||||
vec!["check-one", "missing.pdf", "--edti"],
|
||||
vec!["check", "missing.pdf", "--timout", "2"],
|
||||
vec!["audit", "missing.pdf", "--passwrod", "x"],
|
||||
vec!["audit", "missing.pdf", "--nonsense", "1"],
|
||||
vec!["run", "doc_open", "--roto", "/tmp"],
|
||||
vec!["ui", "--contro", "control.json", "state"],
|
||||
] {
|
||||
let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option");
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
|
||||
assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# Records from every crate go to standard error and the log file (src/logging.rs).
|
||||
log = { workspace = true }
|
||||
# The log timestamp goes through chrono (no hand-rolled calendar code).
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited
|
||||
@@ -54,8 +56,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
|
||||
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
|
||||
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
|
||||
# and `advapi` for the session id that names the single-instance socket (#3338).
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
|
||||
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
|
||||
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
|
||||
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
|
||||
|
||||
@@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most
|
||||
/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
|
||||
/// is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -337,6 +345,18 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! 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):
|
||||
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
|
||||
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
|
||||
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
|
||||
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent>
|
||||
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
|
||||
//! --page-gaps on|off --default-page-gaps on|off
|
||||
//! --combine-view grid|list --combine-zoom 60..200`
|
||||
//!
|
||||
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
|
||||
@@ -55,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";
|
||||
|
||||
@@ -195,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,
|
||||
@@ -557,6 +583,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
|
||||
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
|
||||
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
|
||||
/// its window appeared. D3D12 is the native, best-supported backend there.
|
||||
/// - On Windows, Direct3D 12 presents through a DirectComposition visual rather than a swap chain
|
||||
/// on the window itself, unless `WGPU_DX12_PRESENTATION_SYSTEM` says otherwise. While the window
|
||||
/// is being resized, Windows stretched the window's swap chain, still at the old size, to the
|
||||
/// new one, so the text shimmered and the left panel's width bounced for as long as the drag
|
||||
/// lasted (issue #2738). A visual's last frame is shown at its own size instead.
|
||||
fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
|
||||
@@ -604,6 +635,15 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
|
||||
setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL;
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
setup.instance_descriptor.backend_options.dx12.presentation_system = dx12_presentation(eframe::wgpu::Dx12SwapchainKind::from_env());
|
||||
}
|
||||
}
|
||||
|
||||
/// How Direct3D 12 presents on Windows: through a DirectComposition visual (issue #2738), unless
|
||||
/// `WGPU_DX12_PRESENTATION_SYSTEM` asked for something else (`asked`).
|
||||
fn dx12_presentation(asked: Option<eframe::wgpu::Dx12SwapchainKind>) -> eframe::wgpu::Dx12SwapchainKind {
|
||||
asked.unwrap_or(eframe::wgpu::Dx12SwapchainKind::DxgiFromVisual)
|
||||
}
|
||||
|
||||
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
|
||||
@@ -765,7 +805,7 @@ mod tests {
|
||||
/// graphics libraries at a time. The test threads otherwise run them together, and on
|
||||
/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
|
||||
/// the first device was still being set up when the second test began its own. The UI
|
||||
/// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole
|
||||
/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole
|
||||
/// test (declared before the instance, so it's released after the device is dropped).
|
||||
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
@@ -878,7 +918,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
|
||||
fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() {
|
||||
let mut native = eframe::NativeOptions::default();
|
||||
super::configure_gpu(&mut native);
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
|
||||
@@ -896,6 +936,18 @@ mod tests {
|
||||
assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
|
||||
assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
|
||||
}
|
||||
use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual};
|
||||
assert_eq!(super::dx12_presentation(None), DxgiFromVisual);
|
||||
assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides");
|
||||
assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual);
|
||||
let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system;
|
||||
if cfg!(target_os = "windows") {
|
||||
// A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise.
|
||||
let asked = eframe::wgpu::Dx12SwapchainKind::from_env();
|
||||
assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain");
|
||||
} else {
|
||||
assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1387,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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
|
||||
/// `%LOCALAPPDATA%` is unset.
|
||||
#[cfg(windows)]
|
||||
fn socket_path() -> Option<std::path::PathBuf> {
|
||||
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
|
||||
let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
|
||||
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
|
||||
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
|
||||
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
|
||||
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
|
||||
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
|
||||
}
|
||||
|
||||
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
|
||||
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
|
||||
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
|
||||
/// The Windows session this process runs in, from its access token: the console, each Remote
|
||||
/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
|
||||
#[cfg(windows)]
|
||||
fn session_id() -> Option<u32> {
|
||||
use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
|
||||
match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
|
||||
Ok(TokenInfo::SessionId(id)) => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket's file name for a Windows session. The same user signed in twice (at the console
|
||||
/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
|
||||
/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
|
||||
/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
|
||||
/// window of the session it runs in (#3338), and never one in another session (review on #3340).
|
||||
/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
|
||||
/// digits and `-` kept.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn socket_name(session: Option<&str>) -> String {
|
||||
let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
|
||||
if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
|
||||
fn socket_name(session: Option<u32>, session_name: Option<&str>) -> String {
|
||||
if let Some(id) = session {
|
||||
return format!("instance-s{id}.sock");
|
||||
}
|
||||
let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
|
||||
if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
|
||||
}
|
||||
|
||||
/// Held while a launch finds out whether it is the first, so launches that start together (several
|
||||
@@ -359,12 +377,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn each_windows_session_gets_its_own_socket_name() {
|
||||
assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
|
||||
assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
|
||||
assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
|
||||
assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
|
||||
assert_eq!(socket_name(None), "instance.sock");
|
||||
assert_eq!(socket_name(Some("")), "instance.sock");
|
||||
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
|
||||
// console's session finds the window opened from Explorer there...
|
||||
assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
|
||||
// ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
|
||||
assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
|
||||
assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
|
||||
assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
|
||||
// Only without an id does SESSIONNAME name it.
|
||||
assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
|
||||
assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
|
||||
assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
|
||||
assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
|
||||
assert_eq!(socket_name(None, None), "instance.sock");
|
||||
assert_eq!(socket_name(None, Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -394,11 +420,21 @@ mod tests {
|
||||
assert_eq!(display_socket_name(Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
/// The socket is named by the session the process runs in, which Windows always gives.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn this_process_has_a_session_id() {
|
||||
let id = super::session_id();
|
||||
assert!(id.is_some(), "the access token names the session");
|
||||
let name = socket_name(id, None);
|
||||
assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
|
||||
}
|
||||
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn socket(test: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir.join(socket_name(None))
|
||||
dir.join(socket_name(None, None))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
// Compile winit's scroll policy against its real event type, without a compositor. The vendored
|
||||
// source (`winit_wayland_scroll`) names it as `crate::event`.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod event {
|
||||
pub use winit::event::TouchPhase;
|
||||
}
|
||||
|
||||
mod winit_macos_emoji_picker;
|
||||
mod winit_macos_ime_commit;
|
||||
mod winit_macos_ime_discard;
|
||||
mod winit_macos_korean_first_key;
|
||||
mod winit_macos_korean_space;
|
||||
mod winit_macos_title_bar_drag;
|
||||
mod winit_wayland_scroll;
|
||||
mod winit_wayland_text_input;
|
||||
mod winit_windows_hidden_maximized;
|
||||
mod winit_windows_ime_cursor;
|
||||
mod winit_x11_dpi;
|
||||
mod winit_x11_ime_focus;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-5
@@ -3,11 +3,8 @@
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
|
||||
use wayland_client::protocol::wl_pointer::AxisSource;
|
||||
|
||||
// Compile the production policy against winit's real event type, without a compositor.
|
||||
mod event {
|
||||
pub use winit::event::TouchPhase;
|
||||
}
|
||||
#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
|
||||
use crate::event;
|
||||
#[path = "../../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
|
||||
mod scroll;
|
||||
|
||||
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+49
-49
@@ -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; }
|
||||
|
||||
@@ -401,12 +401,12 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
</div>
|
||||
<h3>The families in this specimen</h3>
|
||||
<div class="families">
|
||||
<div><span style="font-family:'PC Playfair'">Ag</span><b>Playfair Display</b>Display</div>
|
||||
<div><span style="font-family:'PC Garamond'">Ag</span><b>EB Garamond</b>Body text</div>
|
||||
<div><span style="font-family:'PC Inter';font-weight:500">Ag</span><b>Inter</b>Labels</div>
|
||||
<div><span style="font-family:'PC Mono'">Ag</span><b>JetBrains Mono</b>Code</div>
|
||||
<div><span style="font-family:'PC Vibes'">Ag</span><b>Great Vibes</b>Script</div>
|
||||
<div><span style="font-family:'PC Math'">∑x</span><b>STIX Two Math</b>Mathematics</div>
|
||||
<div><span style="font-family:'PC Playfair', serif">Ag</span><b>Playfair Display</b>Display</div>
|
||||
<div><span style="font-family:'PC Garamond', serif">Ag</span><b>EB Garamond</b>Body text</div>
|
||||
<div><span style="font-family:'PC Inter', sans-serif;font-weight:500">Ag</span><b>Inter</b>Labels</div>
|
||||
<div><span style="font-family:'PC Mono', monospace">Ag</span><b>JetBrains Mono</b>Code</div>
|
||||
<div><span style="font-family:'PC Vibes', cursive">Ag</span><b>Great Vibes</b>Script</div>
|
||||
<div><span style="font-family:'PC Math', math">∑x</span><b>STIX Two Math</b>Mathematics</div>
|
||||
</div>
|
||||
<div class="folio"><span>Foreword</span><b>ii</b></div>
|
||||
</section>
|
||||
@@ -449,7 +449,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
<tr><td class="tag">onum</td><td class="lbl">Old-style figures</td><td class="off" style="font-feature-settings:'lnum' 1">Since 1885: 3,467,920</td><td class="on" style="font-feature-settings:'onum' 1">Since 1885: 3,467,920</td></tr>
|
||||
<tr><td class="tag">frac</td><td class="lbl">Fractions</td><td class="off" style="font-family:var(--ui)">1/2 cup, 3/4 inch, 7/8</td><td class="on" style="font-family:var(--ui);font-feature-settings:'frac' 1">1/2 cup, 3/4 inch, 7/8</td></tr>
|
||||
<tr><td class="tag">swsh</td><td class="lbl">Swash capitals</td><td class="off" style="font-style:italic">The Quiet River</td><td class="on" style="font-style:italic;font-feature-settings:'swsh' 1">The Quiet River</td></tr>
|
||||
<tr><td class="tag">ss01</td><td class="lbl">Stylistic set 1</td><td class="off" style="font-family:'PC Inter'">a 1 4 6 9 G ?</td><td class="on" style="font-family:'PC Inter';font-feature-settings:'ss01' 1">a 1 4 6 9 G ?</td></tr>
|
||||
<tr><td class="tag">ss01</td><td class="lbl">Stylistic set 1</td><td class="off" style="font-family:'PC Inter', sans-serif">a 1 4 6 9 G ?</td><td class="on" style="font-family:'PC Inter', sans-serif;font-feature-settings:'ss01' 1">a 1 4 6 9 G ?</td></tr>
|
||||
<tr><td class="tag">kern</td><td class="lbl">Kerning</td><td class="off" style="font-kerning:none;font-family:var(--sans)">AVATAR Type Wave</td><td class="on" style="font-kerning:normal;font-family:var(--sans)">AVATAR Type Wave</td></tr>
|
||||
<tr><td class="tag">tnum</td><td class="lbl">Tabular figures</td><td class="off" style="font-family:var(--ui);font-feature-settings:'pnum' 1">1111 / 8888</td><td class="on" style="font-family:var(--ui);font-feature-settings:'tnum' 1">1111 / 8888</td></tr>
|
||||
</table>
|
||||
@@ -519,9 +519,9 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
<p class="dek">A short greeting — roughly, “every letter deserves a good home” — in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1in;gap:16pt">
|
||||
<div class="scripts">
|
||||
<div class="sc" lang="ar" dir="rtl"><div class="nm" dir="ltr">Arabic <i>RTL · joining</i></div><div class="tx" style="font-family:'PC Amiri'">لكل حرف بيت</div><div class="en" dir="ltr">Amiri</div></div>
|
||||
<div class="sc" lang="ar" dir="rtl"><div class="nm" dir="ltr">Arabic <i>RTL · joining</i></div><div class="tx" style="font-family:'PC Amiri', serif">لكل حرف بيت</div><div class="en" dir="ltr">Amiri</div></div>
|
||||
<div class="sc" lang="he" dir="rtl"><div class="nm" dir="ltr">Hebrew <i>RTL</i></div><div class="tx" style="font-family:'PC Hebrew',sans-serif">לכל אות יש בית טוב</div><div class="en" dir="ltr">Noto Sans Hebrew</div></div>
|
||||
<div class="sc" lang="hi"><div class="nm">Devanagari <i>conjuncts</i></div><div class="tx" style="font-family:'PC Devanagari'">हर अक्षर का घर</div><div class="en">Noto Sans Devanagari</div></div>
|
||||
<div class="sc" lang="hi"><div class="nm">Devanagari <i>conjuncts</i></div><div class="tx" style="font-family:'PC Devanagari', sans-serif">हर अक्षर का घर</div><div class="en">Noto Sans Devanagari</div></div>
|
||||
<div class="sc" lang="th"><div class="nm">Thai <i>no spaces</i></div><div class="tx" style="font-family:'PC Thai',sans-serif">ทุกอักษรมีบ้าน</div><div class="en">Noto Sans Thai</div></div>
|
||||
<div class="sc" lang="el"><div class="nm">Greek <i>polytonic</i></div><div class="tx" style="font-family:'PC Garamond',serif">Κάθε γράμμα ἔχει σπίτι</div><div class="en">EB Garamond</div></div>
|
||||
<div class="sc" lang="ru"><div class="nm">Cyrillic</div><div class="tx" style="font-family:'PC Garamond',serif">Каждой букве — дом</div><div class="en">EB Garamond</div></div>
|
||||
@@ -541,7 +541,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
</div>
|
||||
|
||||
<h3>Mixed direction in one line</h3>
|
||||
<p style="font-size:12pt;margin:0">The word <span lang="ar" style="font-family:'PC Amiri'">كتاب</span> means “book”, and <span lang="he" style="font-family:'PC Hebrew'">ספר</span> does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like <span lang="ar" style="font-family:'PC Amiri'">٣٤٥</span> and 345.</p>
|
||||
<p style="font-size:12pt;margin:0">The word <span lang="ar" style="font-family:'PC Amiri', serif">كتاب</span> means “book”, and <span lang="he" style="font-family:'PC Hebrew', serif">ספר</span> does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like <span lang="ar" style="font-family:'PC Amiri', serif">٣٤٥</span> and 345.</p>
|
||||
|
||||
<h3>Colour emoji</h3>
|
||||
<div class="emoji">📄🖋️🎨📐🔍🌍🧮✅🦀📎</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-move-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m18 8 4 4-4 4" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m6 8-4 4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-pilcrow"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13 4v16" />
|
||||
<path d="M17 4v16" />
|
||||
<path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 380 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-subscript"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m4 5 8 8" />
|
||||
<path d="m12 5-8 8" />
|
||||
<path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-superscript"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m4 19 8-8" />
|
||||
<path d="m12 19-8-8" />
|
||||
<path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 0 0-2.617-.436c-.42.239-.738.614-.899 1.06" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -20,6 +20,11 @@
|
||||
[people.YouseifUS]
|
||||
sync_github_name = true
|
||||
|
||||
[people.brian-moon]
|
||||
real_name = "Il Min Brian Moon"
|
||||
display_name = "Il Min Brian Moon"
|
||||
sync_github_name = false
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -30,6 +35,9 @@ real_name = "Eduardo Soares"
|
||||
display_name = "Eduardo Soares"
|
||||
sync_github_name = false
|
||||
|
||||
[people.guillehr2]
|
||||
real_name = "Guillem Hermida"
|
||||
|
||||
[people.jcl-bit]
|
||||
real_name = "James Locke"
|
||||
display_name = "James Locke"
|
||||
@@ -148,3 +156,27 @@ sync_github_name = false
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
[people.EricKaden]
|
||||
real_name = "Eric Kaden"
|
||||
display_name = "Eric Kaden"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MatrixMagician]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Lcoding15]
|
||||
real_name = "Livio Oswald"
|
||||
|
||||
[people.Bode512]
|
||||
sync_github_name = true
|
||||
|
||||
[people.amoisttowe1]
|
||||
real_name = "Nick Nyssen"
|
||||
|
||||
[people.agent-reed]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
@@ -16,6 +16,10 @@ let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); /
|
||||
- Each result is Passed, Failed (with findings and pages), Needs manual check, or Skipped.
|
||||
- **Content checks** walk the page content streams (and form XObjects) tracking marked
|
||||
content: painting outside MCID-marked or `/Artifact` content is untagged.
|
||||
- **Character encoding** accepts a predefined non-Identity CJK CMap or an Adobe CJK
|
||||
character collection (`GB1`, `CNS1`, `Japan1`, `Korea1`, `KR`), including Identity-H/V.
|
||||
Unknown CMaps and foreign registries do not establish that route. This is a font-level
|
||||
declaration check, not validation of every ToUnicode entry or used glyph.
|
||||
- **Structure checks** read the structure tree through the role map; table regularity counts
|
||||
`ColSpan`/`RowSpan` attributes.
|
||||
- The report is a self-contained HTML page. Fixes that edit the document (language, title, tab
|
||||
|
||||
+158
-69
@@ -4,7 +4,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
/// A Figure element (after the role map).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -23,6 +23,8 @@ pub enum AltError {
|
||||
Untagged,
|
||||
#[error("object {0} is not a figure")]
|
||||
NotAFigure(u32),
|
||||
#[error("the page has more content than can be edited")]
|
||||
TooLarge,
|
||||
#[error("{0}")]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -48,6 +50,8 @@ pub enum ArtifactError {
|
||||
AmbiguousParent,
|
||||
#[error("couldn't update the parent tree: {0}")]
|
||||
ParentTree(String),
|
||||
#[error("the page has more content than can be edited")]
|
||||
TooLarge,
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -157,22 +161,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option<i
|
||||
}
|
||||
|
||||
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
page_content_within(doc, page, &mut DecodeBudget::page())
|
||||
}
|
||||
|
||||
/// The page's content streams, decoded against `budget` and joined with a space. Streams that
|
||||
/// don't decode, or arrive after the budget is spent, are left out.
|
||||
pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec<u8> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
Object::Array(a) => a
|
||||
.iter()
|
||||
.flat_map(|x| match &*doc.resolve(x) {
|
||||
Object::Stream(s) => {
|
||||
let mut v = s.decoded().unwrap_or_default();
|
||||
v.push(b'\n');
|
||||
v
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
let mut out = Vec::new();
|
||||
for data in budget.decode_contents(doc, c).into_iter().flatten() {
|
||||
out.extend_from_slice(&data);
|
||||
out.push(b' ');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
|
||||
@@ -182,17 +183,70 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the marked content `ids` is drawn on a page (images, forms and paths).
|
||||
fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64; 4]> {
|
||||
/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked
|
||||
/// for, and the bounds of what has been drawn in it so far.
|
||||
struct Mark {
|
||||
id: Option<i64>,
|
||||
drawn: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
/// Adds `b` to what the innermost open sequence has drawn.
|
||||
fn draw(marks: &mut [Mark], b: [f64; 4]) {
|
||||
if let Some(m) = marks.last_mut() {
|
||||
m.drawn = Some(union(m.drawn, b));
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it.
|
||||
fn close(marks: &mut Vec<Mark>, matching: &mut usize, out: &mut HashMap<i64, [f64; 4]>) {
|
||||
let Some(m) = marks.pop() else { return };
|
||||
if m.id.is_some() {
|
||||
*matching = matching.saturating_sub(1);
|
||||
}
|
||||
let Some(b) = m.drawn else { return };
|
||||
if let Some(id) = m.id {
|
||||
let e = out.entry(id).or_insert(b);
|
||||
*e = union(Some(*e), b);
|
||||
}
|
||||
draw(marks, b);
|
||||
}
|
||||
|
||||
/// The page-space box a `Do` operator paints: a form's transformed `/BBox`, or the unit square for an image.
|
||||
/// `None` when the operand is not a resolvable XObject or the form's box is malformed.
|
||||
fn xobject_bbox(doc: &Document, xobjects: &Dict, op: &pdfcraft_content::Op, top: &Matrix) -> Option<[f64; 4]> {
|
||||
let r = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)?;
|
||||
Some(match &*doc.get(r) {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => {
|
||||
let bb: Vec<f64> =
|
||||
s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default();
|
||||
let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[]))
|
||||
.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
match bb[..] {
|
||||
[a, b, c, d] => m.then(top).bbox([a, b, c, d]),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass
|
||||
/// over the page. What a sequence draws counts for every sequence of `ids` around it too.
|
||||
pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> HashMap<i64, [f64; 4]> {
|
||||
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let props = page_properties(doc, page);
|
||||
#[cfg(test)]
|
||||
crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1));
|
||||
let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])];
|
||||
let mut marks: Vec<bool> = Vec::new();
|
||||
let mut bbox = None;
|
||||
// The open marked-content sequences, and how many of them are one of `ids`.
|
||||
let mut marks: Vec<Mark> = Vec::new();
|
||||
let mut matching = 0usize;
|
||||
let mut out = HashMap::new();
|
||||
let mut path: Vec<(f64, f64)> = Vec::new();
|
||||
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
|
||||
let inside = marks.iter().any(|m| *m);
|
||||
crate::count_mark_reads(1);
|
||||
let inside = matching > 0;
|
||||
let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
match op.op.as_slice() {
|
||||
b"q" => ctm.push(top),
|
||||
@@ -206,11 +260,13 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
|
||||
*t = m.then(&top);
|
||||
}
|
||||
}
|
||||
b"BMC" => marks.push(false),
|
||||
b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))),
|
||||
b"EMC" => {
|
||||
marks.pop();
|
||||
b"BMC" => marks.push(Mark { id: None, drawn: None }),
|
||||
b"BDC" => {
|
||||
let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n));
|
||||
matching += usize::from(id.is_some());
|
||||
marks.push(Mark { id, drawn: None });
|
||||
}
|
||||
b"EMC" => close(&mut marks, &mut matching, &mut out),
|
||||
b"m" | b"l" => {
|
||||
if let Some([x, y]) = op.nums::<2>() {
|
||||
path.push(top.apply(x, y));
|
||||
@@ -231,33 +287,23 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => {
|
||||
if inside && !matches!(op.op.as_slice(), b"n") {
|
||||
for (x, y) in &path {
|
||||
bbox = Some(union(bbox, [*x, *y, *x, *y]));
|
||||
draw(&mut marks, [*x, *y, *x, *y]);
|
||||
}
|
||||
}
|
||||
path.clear();
|
||||
}
|
||||
b"Do" if inside => {
|
||||
let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue };
|
||||
let unit = match &*doc.get(r) {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => {
|
||||
let bb: Vec<f64> =
|
||||
s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default();
|
||||
let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[]))
|
||||
.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
match bb[..] {
|
||||
[a, b, c, d] => m.then(&top).bbox([a, b, c, d]),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
|
||||
};
|
||||
bbox = Some(union(bbox, unit));
|
||||
let Some(unit) = xobject_bbox(doc, &xobjects, &op, &top) else { continue };
|
||||
draw(&mut marks, unit);
|
||||
}
|
||||
b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))),
|
||||
b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
bbox
|
||||
while !marks.is_empty() {
|
||||
close(&mut marks, &mut matching, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The figures, in document order.
|
||||
@@ -266,14 +312,28 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
|
||||
let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
|
||||
elements(doc)
|
||||
let figures: Vec<(Element, Option<usize>)> = elements(doc)
|
||||
.into_iter()
|
||||
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
|
||||
.map(|e| {
|
||||
let page = e.page.and_then(|p| index.get(&p).copied());
|
||||
(e, page)
|
||||
})
|
||||
.collect();
|
||||
let mut wanted: HashMap<usize, HashSet<i64>> = HashMap::new();
|
||||
for (e, page) in &figures {
|
||||
if let Some(p) = page {
|
||||
wanted.entry(*p).or_default().extend(mcids(&e.dict));
|
||||
}
|
||||
}
|
||||
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
|
||||
wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect();
|
||||
figures
|
||||
.into_iter()
|
||||
.map(|(e, page)| {
|
||||
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
|
||||
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
|
||||
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
|
||||
let on_page = page.and_then(|p| bounds.get(&p));
|
||||
let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
|
||||
Figure { obj: e.object, page, alt, bbox }
|
||||
})
|
||||
.collect()
|
||||
@@ -321,25 +381,43 @@ fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet
|
||||
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet<i64>) -> Result<bool, AltError> {
|
||||
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
|
||||
let mut budget = DecodeBudget::page();
|
||||
for entry in &entries {
|
||||
match &*doc.resolve(entry) {
|
||||
Object::Stream(s) => {
|
||||
let data = match budget.decode(s) {
|
||||
Ok(data) => data,
|
||||
// Content past the budget may hold more of the figure: editing the rest
|
||||
// would leave that part marked as a figure the tree no longer has.
|
||||
Err(_) if budget.exhausted() => return Err(AltError::TooLarge),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
streams.push(Some((s.clone(), data)));
|
||||
}
|
||||
_ => streams.push(None),
|
||||
}
|
||||
}
|
||||
let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect();
|
||||
let pieces = pdfcraft_content::Pieces::join(&decoded);
|
||||
let edits: Vec<_> =
|
||||
pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect();
|
||||
if edits.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut spliced = pieces.splice(edits).into_iter();
|
||||
let mut next = Vec::with_capacity(entries.len());
|
||||
let mut changed = false;
|
||||
for entry in entries {
|
||||
let Some(stream) = (match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
for (entry, stream) in entries.into_iter().zip(streams) {
|
||||
let Some((stream, old)) = stream else {
|
||||
next.push(entry);
|
||||
continue;
|
||||
};
|
||||
let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops;
|
||||
let mut ours = false;
|
||||
for op in &mut ops {
|
||||
if marked(doc, &props, op, ids) {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
ours = true;
|
||||
}
|
||||
}
|
||||
if !ours {
|
||||
let Some(new) = spliced.next() else {
|
||||
next.push(entry);
|
||||
continue;
|
||||
};
|
||||
if new == old {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
@@ -348,15 +426,10 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"Filter");
|
||||
dict.remove(b"DecodeParms");
|
||||
let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops)));
|
||||
let stored = match entry {
|
||||
Object::Ref(r) => {
|
||||
doc.set(r, stream);
|
||||
Object::Ref(r)
|
||||
}
|
||||
_ => Object::Ref(doc.add(stream)),
|
||||
};
|
||||
next.push(stored);
|
||||
let stream = Object::Stream(Stream::flate(dict, &new));
|
||||
// Splicing depends on this occurrence's neighbours. The original stream may also
|
||||
// appear elsewhere in this array or on another page, where it must stay unchanged.
|
||||
next.push(Object::Ref(doc.add(stream)));
|
||||
}
|
||||
if changed {
|
||||
let contents = match next.as_slice() {
|
||||
@@ -418,7 +491,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_ref = element.page.ok_or(ArtifactError::NoPage)?;
|
||||
let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?;
|
||||
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
|
||||
let mut content = Vec::new();
|
||||
if let Some(c) = page.dict.get(b"Contents") {
|
||||
let mut budget = DecodeBudget::page();
|
||||
for data in budget.decode_contents(doc, c) {
|
||||
match data {
|
||||
Ok(data) => {
|
||||
content.extend_from_slice(&data);
|
||||
content.push(b' ');
|
||||
}
|
||||
// The page's /Contents is replaced with what was decoded: content past the
|
||||
// budget would be dropped from the page.
|
||||
Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ops = pdfcraft_content::parse(&content).ops;
|
||||
let mut found = HashSet::new();
|
||||
for op in &mut ops {
|
||||
let id = (op.op == b"BDC")
|
||||
|
||||
+34
-28
@@ -70,7 +70,7 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
|
||||
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
|
||||
};
|
||||
all.extend_from_slice(&decoded);
|
||||
all.push(b'\n');
|
||||
all.push(b' ');
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
@@ -132,6 +132,38 @@ fn harmless_inside(op: &[u8]) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged.
|
||||
fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagError> {
|
||||
let mut start = None;
|
||||
let mut has_text = false;
|
||||
let mut spans = Vec::new();
|
||||
for (at, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"BT" if start.is_none() => {
|
||||
start = Some(at);
|
||||
has_text = false;
|
||||
}
|
||||
b"ET" => {
|
||||
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
|
||||
if has_text {
|
||||
spans.push((first, at));
|
||||
if spans.len() > MAX_TEXT_OBJECTS {
|
||||
return Err(unsupported(index, "too many text objects (maximum 4096)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
|
||||
name if start.is_some() && harmless_inside(name) => {}
|
||||
name if start.is_none() && harmless_outside(name) => {}
|
||||
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
|
||||
}
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(index, "unterminated BT text object"));
|
||||
}
|
||||
Ok(spans)
|
||||
}
|
||||
|
||||
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
let root = doc.root().ok_or(AutoTagError::NoCatalog)?;
|
||||
let catalog = doc.get(root);
|
||||
@@ -163,33 +195,7 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
|
||||
return Err(unsupported(index, "content has malformed or too many operators"));
|
||||
}
|
||||
let mut start = None;
|
||||
let mut has_text = false;
|
||||
let mut spans = Vec::new();
|
||||
for (at, op) in parsed.ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"BT" if start.is_none() => {
|
||||
start = Some(at);
|
||||
has_text = false;
|
||||
}
|
||||
b"ET" => {
|
||||
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
|
||||
if has_text {
|
||||
spans.push((first, at));
|
||||
if spans.len() > MAX_TEXT_OBJECTS {
|
||||
return Err(unsupported(index, "too many text objects (maximum 4096)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
|
||||
name if start.is_some() && harmless_inside(name) => {}
|
||||
name if start.is_none() && harmless_outside(name) => {}
|
||||
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
|
||||
}
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(index, "unterminated BT text object"));
|
||||
}
|
||||
let spans = text_spans(&parsed.ops, index)?;
|
||||
total = total.saturating_add(spans.len());
|
||||
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
|
||||
}
|
||||
|
||||
+117
-45
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_model::Page;
|
||||
|
||||
use crate::{Finding, Rule};
|
||||
@@ -20,6 +20,8 @@ pub(crate) struct Scan {
|
||||
pub untagged: BTreeMap<usize, usize>,
|
||||
/// Page → fonts used.
|
||||
pub fonts: BTreeMap<usize, BTreeSet<ObjRef>>,
|
||||
/// Pages read only in part: content or forms past the page's decode budget are missing.
|
||||
pub incomplete: BTreeSet<usize>,
|
||||
}
|
||||
|
||||
const MAX_FORM_DEPTH: usize = 12;
|
||||
@@ -30,38 +32,34 @@ impl Scan {
|
||||
for &p in list {
|
||||
let Some(page) = pages.get(p) else { continue };
|
||||
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let data = page_content(doc, &page.dict);
|
||||
let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() };
|
||||
let mut budget = DecodeBudget::page();
|
||||
let data = crate::alt::page_content_within(doc, &page.dict, &mut budget);
|
||||
if budget.remaining() == 0 {
|
||||
s.incomplete.insert(p);
|
||||
}
|
||||
let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget };
|
||||
w.content(&data, &res, false, 0);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
Object::Array(a) => a
|
||||
.iter()
|
||||
.flat_map(|x| match &*doc.resolve(x) {
|
||||
Object::Stream(s) => {
|
||||
let mut v = s.decoded().unwrap_or_default();
|
||||
v.push(b'\n');
|
||||
v
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
doc: &'a Document,
|
||||
scan: &'a mut Scan,
|
||||
page: usize,
|
||||
forms: HashSet<ObjRef>,
|
||||
/// Decoded bytes and form entries left for this page.
|
||||
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<'_> {
|
||||
@@ -79,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();
|
||||
}
|
||||
@@ -116,14 +104,27 @@ impl Walk<'_> {
|
||||
self.scan.has_images = true;
|
||||
untagged += usize::from(!inside);
|
||||
}
|
||||
Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => {
|
||||
Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => {
|
||||
if !self.budget.enter_form() {
|
||||
self.scan.incomplete.insert(self.page);
|
||||
continue;
|
||||
}
|
||||
let data = match self.budget.decode(x) {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
if self.budget.exhausted() {
|
||||
self.scan.incomplete.insert(self.page);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.forms.insert(r);
|
||||
let inner = x
|
||||
.dict
|
||||
.get(b"Resources")
|
||||
.map(|o| self.doc.resolve(o))
|
||||
.and_then(|o| o.as_dict().cloned())
|
||||
.unwrap_or_else(|| res.clone());
|
||||
let data = x.decoded().unwrap_or_default();
|
||||
self.content(&data, &inner, inside, depth + 1);
|
||||
self.forms.remove(&r);
|
||||
}
|
||||
@@ -143,6 +144,28 @@ fn catalog(doc: &Document) -> Dict {
|
||||
doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The most references one value may be written through. A chain is unusual but legal, and the
|
||||
/// render inspector follows them (lopdf's `dereference` has a limit of its own), so the two
|
||||
/// readers of a flag must agree about one.
|
||||
const MAX_REFERENCE_CHAIN: usize = 32;
|
||||
|
||||
/// The value a chain of indirect references ends at. [`Document::resolve`] follows one reference,
|
||||
/// and an indirect object's value may itself be a reference (ISO 32000-2 §7.3.10): `/Marked
|
||||
/// 8 0 R` with object 8 holding `9 0 R` holding `true` is a tagged document, which this rule read
|
||||
/// as untagged while `doc_info` read it as tagged. Bounded and cycle-safe, since a damaged file
|
||||
/// can point a reference at itself; a chain longer than that, or one that loops, is no value.
|
||||
fn resolved(doc: &Document, o: &Object) -> std::sync::Arc<Object> {
|
||||
let mut seen: HashSet<ObjRef> = HashSet::new();
|
||||
let mut value = doc.resolve(o);
|
||||
while let Object::Ref(r) = &*value {
|
||||
if seen.len() >= MAX_REFERENCE_CHAIN || !seen.insert(*r) {
|
||||
return std::sync::Arc::new(Object::Null);
|
||||
}
|
||||
value = doc.get(*r);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
|
||||
}
|
||||
@@ -167,12 +190,16 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
|
||||
}
|
||||
}
|
||||
Rule::TaggedPdf => {
|
||||
let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned()));
|
||||
// `/Marked` may be an indirect reference like any other value -- or a chain of
|
||||
// them -- so it is followed to the value before the test below, which looks for the
|
||||
// boolean itself (#3671).
|
||||
let marked =
|
||||
cat.get(b"MarkInfo").map(|m| resolved(doc, m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").map(|v| resolved(doc, v))));
|
||||
let mut out = Vec::new();
|
||||
if !cat.contains(b"StructTreeRoot") {
|
||||
out.push(Finding::plain(None, "The document has no tags (structure tree)"));
|
||||
}
|
||||
if !matches!(marked, Some(Object::Bool(true))) {
|
||||
if !matches!(marked.as_deref(), Some(Object::Bool(true))) {
|
||||
out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)"));
|
||||
}
|
||||
out
|
||||
@@ -360,8 +387,47 @@ fn full_name(doc: &Document, field: ObjRef) -> String {
|
||||
parts.join(".")
|
||||
}
|
||||
|
||||
/// Whether text in this font can be mapped to Unicode (ToUnicode, a standard encoding, or a
|
||||
/// predefined CJK character collection).
|
||||
/// Predefined CJK CMaps (ISO 32000-2 §9.7.5.2, Table 116), excluding Identity-H/V.
|
||||
/// Test complete names: neither a custom CMap nor a nonexistent vertical variant is predefined.
|
||||
fn predefined_cjk_cmap(name: &[u8]) -> bool {
|
||||
if matches!(name, b"H" | b"V" | b"83pv-RKSJ-H" | b"90pv-RKSJ-H" | b"KSCpc-EUC-H" | b"UniAKR-UTF8-H" | b"UniAKR-UTF16-H" | b"UniAKR-UTF32-H") {
|
||||
return true;
|
||||
}
|
||||
let Some(base) = name.strip_suffix(b"-H").or_else(|| name.strip_suffix(b"-V")) else { return false };
|
||||
matches!(
|
||||
base,
|
||||
b"GB-EUC"
|
||||
| b"GBpc-EUC"
|
||||
| b"GBK-EUC"
|
||||
| b"GBKp-EUC"
|
||||
| b"GBK2K"
|
||||
| b"UniGB-UCS2"
|
||||
| b"UniGB-UTF16"
|
||||
| b"B5pc"
|
||||
| b"HKscs-B5"
|
||||
| b"ETen-B5"
|
||||
| b"ETenms-B5"
|
||||
| b"CNS-EUC"
|
||||
| b"UniCNS-UCS2"
|
||||
| b"UniCNS-UTF16"
|
||||
| b"90ms-RKSJ"
|
||||
| b"90msp-RKSJ"
|
||||
| b"Add-RKSJ"
|
||||
| b"EUC"
|
||||
| b"Ext-RKSJ"
|
||||
| b"UniJIS-UCS2"
|
||||
| b"UniJIS-UCS2-HW"
|
||||
| b"UniJIS-UTF16"
|
||||
| b"KSC-EUC"
|
||||
| b"KSCms-UHC"
|
||||
| b"KSCms-UHC-HW"
|
||||
| b"UniKS-UCS2"
|
||||
| b"UniKS-UTF16"
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether text in this font declares a Unicode mapping route (ToUnicode, a standard encoding,
|
||||
/// a predefined CJK CMap, or an Adobe CJK character collection). This does not validate every glyph.
|
||||
fn maps_to_unicode(doc: &Document, f: &Dict) -> bool {
|
||||
if f.contains(b"ToUnicode") {
|
||||
return true;
|
||||
@@ -369,16 +435,22 @@ fn maps_to_unicode(doc: &Document, f: &Dict) -> bool {
|
||||
let enc = f.get(b"Encoding").map(|e| doc.resolve(e));
|
||||
match f.name(b"Subtype") {
|
||||
Some(b"Type0") => {
|
||||
let identity = matches!(enc.as_deref(), Some(Object::Name(n)) if n.starts_with(b"Identity"));
|
||||
let ordering = f
|
||||
// ISO 32000-2 §9.10.2: either a predefined non-Identity CMap or the descendant's
|
||||
// Adobe CJK collection supplies the route. Identity-H/V do not rule out the latter.
|
||||
if matches!(enc.as_deref(), Some(Object::Name(n)) if predefined_cjk_cmap(n)) {
|
||||
return true;
|
||||
}
|
||||
let system = f
|
||||
.get(b"DescendantFonts")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().and_then(|a| a.first().cloned()))
|
||||
.and_then(|d| doc.resolve(&d).as_dict().cloned())
|
||||
.and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()))
|
||||
.and_then(|c| c.get(b"Ordering").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone())));
|
||||
let known = matches!(ordering.as_deref(), Some(b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR"));
|
||||
!identity && known
|
||||
.and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()));
|
||||
let Some(system) = system else { return false };
|
||||
let registry = system.get(b"Registry").map(|r| doc.resolve(r));
|
||||
let ordering = system.get(b"Ordering").map(|o| doc.resolve(o));
|
||||
matches!(registry.as_deref(), Some(Object::String(s)) if s.bytes == b"Adobe")
|
||||
&& matches!(ordering.as_deref(), Some(Object::String(s)) if matches!(s.bytes.as_slice(), b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR"))
|
||||
}
|
||||
Some(b"Type3") => enc.is_some(),
|
||||
_ => match enc.as_deref() {
|
||||
|
||||
+22
-15
@@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
let res = sub(doc, page, b"Resources");
|
||||
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
|
||||
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
|
||||
// The id each open marked-content sequence is in: its own, else the enclosing one's.
|
||||
let mut stack: Vec<Option<i64>> = Vec::new();
|
||||
let mut out: HashMap<i64, String> = HashMap::new();
|
||||
// Where each id's last glyph ended, in user space.
|
||||
@@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
let mut unsaved = 0usize;
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
|
||||
let id = stack.iter().rev().find_map(|m| *m);
|
||||
crate::count_mark_reads(1);
|
||||
let id = stack.last().copied().flatten();
|
||||
match op.op.as_slice() {
|
||||
b"BMC" => stack.push(None),
|
||||
b"BDC" => stack.push(match op.operands.get(1) {
|
||||
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
|
||||
Some(Object::Name(n)) => {
|
||||
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
|
||||
b"BMC" => stack.push(id),
|
||||
b"BDC" => stack.push(
|
||||
match op.operands.get(1) {
|
||||
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
|
||||
Some(Object::Name(n)) => {
|
||||
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
.or(id),
|
||||
),
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
@@ -263,7 +268,9 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
tm = tlm;
|
||||
}
|
||||
let pieces: Vec<&Object> = match op.op.as_slice() {
|
||||
b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
|
||||
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still takes the
|
||||
// one immediately before it, as the renderer and every other reader here do (#3569).
|
||||
b"TJ" => op.operands.last().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
|
||||
_ => op.operands.last().into_iter().collect(),
|
||||
};
|
||||
for piece in pieces {
|
||||
@@ -278,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
};
|
||||
for (code, len) in m.codes(&s.bytes) {
|
||||
let trm = tm.then(&gs.ctm);
|
||||
// A glyph's text is only built while its id still takes more.
|
||||
if let Some(id) = id
|
||||
&& out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4)
|
||||
&& let Some(glyph) = m.text_of(code)
|
||||
{
|
||||
let text = out.entry(id).or_default();
|
||||
if text.len() < MAX_TITLE * 4 {
|
||||
let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
|
||||
if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&glyph);
|
||||
let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
|
||||
if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&glyph);
|
||||
}
|
||||
let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 };
|
||||
tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm);
|
||||
|
||||
@@ -21,6 +21,22 @@ mod report;
|
||||
mod structure;
|
||||
mod tags;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Marked-content stack entries read to decide which marks enclose an operator, for the tests
|
||||
/// that keep that constant per operator.
|
||||
static MARK_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// Page content passes made to find figure bounds.
|
||||
static BOUNDS_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Counts `n` marked-content stack reads (tests only).
|
||||
#[inline]
|
||||
fn count_mark_reads(_n: usize) {
|
||||
#[cfg(test)]
|
||||
MARK_READS.with(|c| c.set(c.get().saturating_add(_n)));
|
||||
}
|
||||
|
||||
pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt};
|
||||
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
|
||||
pub use autotag_fields::tag_form_fields;
|
||||
@@ -665,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A page read only in part may hold what the scan didn't find: text that makes the
|
||||
// document more than images, untagged content, or a font without Unicode mappings.
|
||||
let unsure = !scan.incomplete.is_empty()
|
||||
&& match rule {
|
||||
Rule::ImageOnly => !scan.has_text,
|
||||
Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
if unsure {
|
||||
results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() });
|
||||
continue;
|
||||
}
|
||||
if total > MAX_FINDINGS {
|
||||
let more = total - MAX_FINDINGS;
|
||||
findings.truncate(MAX_FINDINGS);
|
||||
|
||||
+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))),
|
||||
|
||||
@@ -192,6 +192,44 @@ fn an_untagged_document_fails_the_document_rules() {
|
||||
assert_eq!((only.count(Status::Failed), only.count(Status::Skipped)), (1, 31));
|
||||
}
|
||||
|
||||
/// `/MarkInfo /Marked` may be written as an indirect reference, like any value. The rule read the
|
||||
/// reference itself and so reported a tagged document as untagged (#3671).
|
||||
#[test]
|
||||
fn marked_as_an_indirect_reference_still_counts_as_tagged() {
|
||||
let mut bodies = good();
|
||||
// The catalog is object 1; point /Marked at a new object holding `true`.
|
||||
let last = bodies.len() + 1;
|
||||
bodies[0] = bodies[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {last} 0 R >>"));
|
||||
bodies.push("true".into());
|
||||
let doc = pdf(&bodies, "");
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedPdf].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report));
|
||||
|
||||
// A reference to a reference is followed too: the render inspector follows chains, so the
|
||||
// two readers of the flag would otherwise disagree about one file.
|
||||
let mut chain = good();
|
||||
let first = chain.len() + 1;
|
||||
chain[0] = chain[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {first} 0 R >>"));
|
||||
chain.push(format!("{} 0 R", first + 1));
|
||||
chain.push("true".into());
|
||||
let report = check(&pdf(&chain, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report));
|
||||
|
||||
// A reference that points at itself is no value, not a hang.
|
||||
let mut loops = good();
|
||||
let at = loops.len() + 1;
|
||||
loops[0] = loops[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {at} 0 R >>"));
|
||||
loops.push(format!("{at} 0 R"));
|
||||
let report = check(&pdf(&loops, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Failed);
|
||||
|
||||
// The direct form is unchanged, and a document with no flag at all still fails.
|
||||
assert_eq!(status(&check(&pdf(&good(), ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Passed);
|
||||
let mut none = good();
|
||||
none[0] = none[0].replace("/MarkInfo << /Marked true >>", "");
|
||||
assert_eq!(status(&check(&pdf(&none, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Failed);
|
||||
}
|
||||
|
||||
/// A tagged document that passes every automatic rule.
|
||||
fn good() -> Vec<String> {
|
||||
vec![
|
||||
@@ -329,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()]);
|
||||
@@ -536,6 +671,126 @@ fn scans_identity_fonts_and_long_documents() {
|
||||
assert_eq!(some.result(Rule::CharacterEncoding).unwrap().findings[0].page, Some(3));
|
||||
}
|
||||
|
||||
fn cid_encoding_pdf(encoding: &str, system: &str, to_unicode: bool) -> Document {
|
||||
let unicode = if to_unicode { "/ToUnicode 7 0 R" } else { "" };
|
||||
pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream("/P << /MCID 0 >> BDC BT /F1 12 Tf <002200230024> Tj ET EMC"),
|
||||
format!("<< /Type /Font /Subtype /Type0 /BaseFont /SyntheticCID /Encoding {encoding} /DescendantFonts [6 0 R] {unicode} >>"),
|
||||
format!("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /SyntheticCID /CIDSystemInfo {system} >>"),
|
||||
stream(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /SyntheticUnicode def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 3 beginbfchar <0022> <0041> <0023> <0042> <0024> <0043> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
|
||||
),
|
||||
],
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_accepts_identity_cjk_collections() {
|
||||
for encoding in ["/Identity-H", "/Identity-V"] {
|
||||
for ordering in ["Japan1", "GB1", "CNS1", "Korea1", "KR"] {
|
||||
let system = format!("<< /Registry (Adobe) /Ordering ({ordering}) /Supplement 0 >>");
|
||||
let doc = cid_encoding_pdf(encoding, &system, false);
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
let result = report.result(Rule::CharacterEncoding).unwrap();
|
||||
assert_eq!(result.status, Status::Passed, "{encoding}, Adobe-{ordering}: {:?}", result.findings);
|
||||
assert!(result.findings.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_accepts_predefined_cmaps_without_a_known_collection() {
|
||||
// The predefined CMap and the descendant's character collection are alternative routes.
|
||||
let system = "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>";
|
||||
for name in [
|
||||
"GB-EUC-H",
|
||||
"GB-EUC-V",
|
||||
"GBK2K-H",
|
||||
"GBK2K-V",
|
||||
"UniGB-UTF16-H",
|
||||
"UniGB-UTF16-V",
|
||||
"B5pc-H",
|
||||
"B5pc-V",
|
||||
"UniCNS-UCS2-H",
|
||||
"UniCNS-UCS2-V",
|
||||
"83pv-RKSJ-H",
|
||||
"90pv-RKSJ-H",
|
||||
"90ms-RKSJ-H",
|
||||
"90ms-RKSJ-V",
|
||||
"H",
|
||||
"V",
|
||||
"UniJIS-UCS2-HW-H",
|
||||
"UniJIS-UCS2-HW-V",
|
||||
"KSCpc-EUC-H",
|
||||
"KSC-EUC-V",
|
||||
"KSCms-UHC-HW-H",
|
||||
"KSCms-UHC-HW-V",
|
||||
"UniKS-UTF16-H",
|
||||
"UniKS-UTF16-V",
|
||||
"UniAKR-UTF8-H",
|
||||
"UniAKR-UTF16-H",
|
||||
"UniAKR-UTF32-H",
|
||||
] {
|
||||
let doc = cid_encoding_pdf(&format!("/{name}"), system, false);
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_rejects_unknown_cmaps_and_foreign_collections() {
|
||||
for encoding in
|
||||
["/Identity-H", "/Identity-V", "/Custom-H", "/UniCustom-UTF16-H", "/UniJIS-UCS2-HW-extra-H", "/83pv-RKSJ-V", "/KSCpc-EUC-V", "null"]
|
||||
{
|
||||
for system in [
|
||||
"<< /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>",
|
||||
"<< /Registry (Adobe) /Ordering (Custom) /Supplement 0 >>",
|
||||
"<< /Registry (Private) /Ordering (Japan1) /Supplement 0 >>",
|
||||
"<< /Registry (adobe) /Ordering (Japan1) /Supplement 0 >>",
|
||||
"<< /Ordering (Japan1) /Supplement 0 >>",
|
||||
"<< /Registry /Adobe /Ordering /Japan1 /Supplement 0 >>",
|
||||
"null",
|
||||
] {
|
||||
let doc = cid_encoding_pdf(encoding, system, false);
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
let result = report.result(Rule::CharacterEncoding).unwrap();
|
||||
assert_eq!(result.status, Status::Failed, "{encoding}, {system}");
|
||||
assert_eq!(result.findings.len(), 1);
|
||||
assert_eq!(result.findings[0].page, Some(0));
|
||||
assert!(result.findings[0].message.contains("SyntheticCID"));
|
||||
}
|
||||
}
|
||||
let doc = cid_encoding_pdf("/Identity-H", "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>", true);
|
||||
assert_eq!(status(&checked(&doc, &[Rule::CharacterEncoding]), Rule::CharacterEncoding), Status::Passed, "ToUnicode is still sufficient");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_resolves_indirect_cid_metadata() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, PdfString};
|
||||
let mut doc = cid_encoding_pdf("/Identity-H", "null", false);
|
||||
let registry = doc.add(Object::String(PdfString::literal(b"Adobe".to_vec())));
|
||||
let ordering = doc.add(Object::String(PdfString::literal(b"Japan1".to_vec())));
|
||||
let mut system = Dict::new();
|
||||
system.set(b"Registry".to_vec(), Object::Ref(registry));
|
||||
system.set(b"Ordering".to_vec(), Object::Ref(ordering));
|
||||
let system = doc.add(Object::Dict(system));
|
||||
doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"CIDSystemInfo".to_vec(), Object::Ref(system))).unwrap();
|
||||
let descendants = doc.add(Object::Array(vec![Object::Ref(ObjRef::new(6, 0))]));
|
||||
let encoding = doc.add(Object::Name(b"Identity-V".to_vec()));
|
||||
doc.update_dict(ObjRef::new(5, 0), |d| {
|
||||
d.set(b"DescendantFonts".to_vec(), Object::Ref(descendants));
|
||||
d.set(b"Encoding".to_vec(), Object::Ref(encoding));
|
||||
})
|
||||
.unwrap();
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_have_ids_and_the_report_lists_them() {
|
||||
assert_eq!(Rule::ALL.len(), 32);
|
||||
@@ -865,6 +1120,67 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String {
|
||||
ops + " ET"
|
||||
}
|
||||
|
||||
/// Marked-content stack reads while `f` runs.
|
||||
fn mark_reads(f: impl FnOnce()) -> usize {
|
||||
MARK_READS.with(|c| c.set(0));
|
||||
f();
|
||||
MARK_READS.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside.
|
||||
fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict {
|
||||
use pdfcraft_cos::{Dict, Stream};
|
||||
let mut data = "/Figure << /MCID 0 >> BDC ".to_string();
|
||||
data.push_str(&"/X BMC ".repeat(depth));
|
||||
data.push_str(&"0 0 1 1 re f ".repeat(ops));
|
||||
data.push_str(&"EMC ".repeat(depth + 1));
|
||||
let content = doc.add(Stream::flate(Dict::new(), data.as_bytes()));
|
||||
[(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_text_reads_its_enclosing_id_in_constant_steps() {
|
||||
let mut doc = Document::new_empty();
|
||||
let page = deeply_nested_page(&mut doc, 5_000, 5_000);
|
||||
let reads = mark_reads(|| {
|
||||
crate::headings::mcid_texts(&doc, &page);
|
||||
});
|
||||
// About 15 000 operators; a scan of the stack per operator reads millions of entries.
|
||||
assert!(reads <= 4 * 15_000, "{reads} stack reads");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn figure_bounds_read_the_marked_content_state_in_constant_steps() {
|
||||
let mut doc = Document::new_empty();
|
||||
let page = deeply_nested_page(&mut doc, 5_000, 5_000);
|
||||
// No mark encloses figure 7, so a scan of the stack per operator reads all of it.
|
||||
let reads = mark_reads(|| {
|
||||
crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7]));
|
||||
});
|
||||
assert!(reads <= 4 * 15_000, "{reads} stack reads");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn figure_bounds_take_one_pass_over_each_page() {
|
||||
let n = 50;
|
||||
let content: Vec<String> = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect();
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect();
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 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 >>".into(),
|
||||
stream(&content.join(" ")),
|
||||
format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")),
|
||||
];
|
||||
objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>")));
|
||||
let doc = pdf(&objs, "");
|
||||
BOUNDS_PASSES.with(|c| c.set(0));
|
||||
let f = figures(&doc);
|
||||
assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes");
|
||||
assert_eq!(f.len(), n);
|
||||
assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0])));
|
||||
}
|
||||
|
||||
/// One tagged page whose H1 headings show `contents` (one marked-content sequence each).
|
||||
fn heading_page(contents: &[String]) -> Document {
|
||||
let marked: Vec<String> = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect();
|
||||
@@ -926,6 +1242,16 @@ fn an_untagged_document_has_no_headings() {
|
||||
assert!(headings(&pdf(&objs, "")).is_empty());
|
||||
}
|
||||
|
||||
/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still
|
||||
/// draws only the one immediately before it, as the renderer and every other reader agree (#3569)
|
||||
/// -- not the first, which is never drawn at all.
|
||||
#[test]
|
||||
fn a_heading_with_a_tj_surplus_array_reads_the_one_the_page_draws() {
|
||||
let doc = heading_page(&["BT /F1 20 Tf 72 700 Td [(FIRSTARR)] [(LASTARR)] TJ ET".into()]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["LASTARR"]);
|
||||
}
|
||||
|
||||
/// The text of every `"…"` literal with a space in it, outside comments and byte strings.
|
||||
fn sentences(source: &str) -> Vec<String> {
|
||||
let b = source.as_bytes();
|
||||
@@ -1023,3 +1349,75 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() {
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["I Overview"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream};
|
||||
let mut doc = Document::new_empty();
|
||||
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect();
|
||||
let data = crate::alt::page_content(&doc, &page);
|
||||
assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len());
|
||||
}
|
||||
|
||||
/// A figure whose marked content goes on in a stream past the page's decode budget is refused,
|
||||
/// not removed with that content still pointing at it.
|
||||
#[test]
|
||||
fn marking_decorative_refuses_content_past_the_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let mut doc = figures_doc();
|
||||
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec()));
|
||||
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
|
||||
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
|
||||
contents.push(Object::Ref(last));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
|
||||
assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err());
|
||||
assert_eq!(figures(&doc).len(), 2, "the figure stays");
|
||||
}
|
||||
|
||||
/// Text past the page's decode budget is unread, so a page that shows images and no text so far
|
||||
/// can't be called image-only, nor its content all tagged.
|
||||
#[test]
|
||||
fn content_past_the_decode_budget_needs_a_manual_check() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>"
|
||||
.into(),
|
||||
stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"),
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream"
|
||||
.into(),
|
||||
FONT.into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec()));
|
||||
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
|
||||
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
|
||||
contents.push(Object::Ref(text));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
|
||||
let report = check(&doc, &Options::default());
|
||||
assert_eq!(status(&report, Rule::ImageOnly), Status::Manual);
|
||||
assert_eq!(status(&report, Rule::TaggedContent), Status::Manual);
|
||||
}
|
||||
|
||||
/// Marking a tag as an artifact replaces the page's content with what it decoded, so content past
|
||||
/// the page's decode budget is refused rather than dropped from the page.
|
||||
#[test]
|
||||
fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let mut doc = figures_doc();
|
||||
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let last = doc.add(Stream::from_raw(Dict::new(), b"0 0 5 5 re f".to_vec()));
|
||||
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
|
||||
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
|
||||
contents.push(Object::Ref(last));
|
||||
let contents = Object::Array(contents);
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), contents.clone())).unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
|
||||
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use pdfcraft_a11y::{Options, Rule, Status, autotag_text, check, figures, mark_decorative, mark_tag_artifact, tag_content, tags};
|
||||
use pdfcraft_content::{Op, Pieces};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
|
||||
|
||||
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
|
||||
const HIDDEN_AND_AFTER: &str = "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\nBT /F1 12 Tf 72 500 Td (After) Tj ET";
|
||||
|
||||
fn stream(data: &str) -> String {
|
||||
format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())
|
||||
}
|
||||
|
||||
fn build_pdf(objects: &[(u32, String)]) -> Document {
|
||||
let mut raw = Vec::from(&b"%PDF-1.7\n"[..]);
|
||||
let max_num = objects.iter().map(|(n, _)| *n).max().unwrap_or(0);
|
||||
let mut offsets = std::collections::HashMap::new();
|
||||
for (num, body) in objects {
|
||||
offsets.insert(*num, raw.len());
|
||||
raw.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
|
||||
}
|
||||
let xref = raw.len();
|
||||
let size = max_num + 1;
|
||||
raw.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
|
||||
for num in 1..size {
|
||||
if let Some(off) = offsets.get(&num) {
|
||||
raw.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
} else {
|
||||
raw.extend_from_slice(b"0000000000 65535 f \n");
|
||||
}
|
||||
}
|
||||
raw.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
Document::open(std::sync::Arc::new(raw)).expect("valid synthetic PDF")
|
||||
}
|
||||
|
||||
fn untagged_two_streams(first: &str, second: &str) -> Document {
|
||||
build_pdf(&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into()),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [5 0 R 6 0 R] /Resources << /Font << /F1 4 0 R >> >> >>".into()),
|
||||
(4, FONT.into()),
|
||||
(5, stream(first)),
|
||||
(6, stream(second)),
|
||||
])
|
||||
}
|
||||
|
||||
fn tagged_figure_two_streams(after_figure: &str, second: &str) -> Document {
|
||||
let figure = "/Figure << /MCID 0 >> BDC q 50 0 0 50 72 600 cm /Im1 Do Q EMC\n";
|
||||
let first = format!("{figure}{after_figure}");
|
||||
build_pdf(&[
|
||||
(
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>"
|
||||
.into(),
|
||||
),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
|
||||
(
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /StructParents 0 /Contents [10 0 R 11 0 R] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> >>".into(),
|
||||
),
|
||||
(4, FONT.into()),
|
||||
(
|
||||
5,
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\x00\x7f\x40\x20\nendstream".into(),
|
||||
),
|
||||
(6, "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 9 0 R >>".into()),
|
||||
(7, "<< /Type /StructElem /S /Document /P 6 0 R /K [8 0 R] >>".into()),
|
||||
(
|
||||
8,
|
||||
"<< /Type /StructElem /S /Figure /P 7 0 R /Pg 3 0 R /K 0 /Alt (A grey square) >>"
|
||||
.into(),
|
||||
),
|
||||
(9, "<< /Nums [0 [8 0 R]] >>".into()),
|
||||
(10, stream(&first)),
|
||||
(11, stream(second)),
|
||||
])
|
||||
}
|
||||
|
||||
fn joined_ops(doc: &Document) -> (Vec<Op>, usize) {
|
||||
let page = &pdfcraft_model::pages(doc)[0];
|
||||
let contents = page.dict.get(b"Contents").expect("page contents");
|
||||
let pieces: Vec<Vec<u8>> = match &*doc.resolve(contents) {
|
||||
Object::Stream(s) => vec![s.decoded().expect("decoded stream")],
|
||||
Object::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(|item| match &*doc.resolve(item) {
|
||||
Object::Stream(s) => Some(s.decoded().expect("decoded stream")),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let joined = Pieces::join(&pieces);
|
||||
let parsed = joined.parse();
|
||||
(parsed.ops, parsed.skipped)
|
||||
}
|
||||
|
||||
fn shown_strings(ops: &[Op]) -> Vec<String> {
|
||||
ops.iter().filter(|op| op.is("Tj")).filter_map(|op| op.operands.first()?.as_string().map(|s| s.to_text())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_leaves_the_other_stream_untouched() {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let before = doc.get(ObjRef::new(11, 0)).clone();
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
assert!(figures(&doc).is_empty());
|
||||
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
|
||||
let page = doc.get(ObjRef::new(3, 0));
|
||||
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
|
||||
}
|
||||
|
||||
/// A and B supply different operands to R's BDC. Reusing R must not let the first
|
||||
/// occurrence's splice remove the unselected second occurrence's BDC.
|
||||
fn split_marked_content(shared: bool) -> Document {
|
||||
let mut doc = tagged_figure_two_streams("", "");
|
||||
doc.set(ObjRef::new(10, 0), Object::Stream(Stream::flate(Dict::new(), b"/Figure << /MCID 0 >>")));
|
||||
let body = Object::Stream(Stream::flate(Dict::new(), b"BDC 0 0 10 10 re f EMC"));
|
||||
doc.set(ObjRef::new(11, 0), body.clone());
|
||||
let b = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Span << /MCID 1 >>")));
|
||||
let second = if shared { ObjRef::new(11, 0) } else { doc.add(body) };
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| {
|
||||
p.set(
|
||||
b"Contents".to_vec(),
|
||||
Object::Array(vec![Object::Ref(ObjRef::new(10, 0)), Object::Ref(ObjRef::new(11, 0)), Object::Ref(b), Object::Ref(second)]),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
Document::open(std::sync::Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()
|
||||
}
|
||||
|
||||
fn assert_selected_occurrence_only(shared: bool) {
|
||||
let mut doc = split_marked_content(shared);
|
||||
let (before, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(before.iter().filter(|op| op.is("BDC")).count(), 2);
|
||||
let figure = figures(&doc)[0].obj;
|
||||
mark_decorative(&mut doc, figure).unwrap();
|
||||
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
|
||||
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let (ops, skipped) = joined_ops(&reopened);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(ops.iter().filter(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")).count(), 1);
|
||||
let unselected: Vec<_> = ops.iter().filter(|op| op.is("BDC")).collect();
|
||||
assert_eq!(unselected.len(), 1, "unselected Span must retain its BDC after save/reopen");
|
||||
assert_eq!(unselected[0].name(0), Some(b"Span".as_slice()));
|
||||
assert_eq!(unselected[0].operands[1].as_dict().unwrap().get(b"MCID").and_then(Object::as_int), Some(1));
|
||||
assert_eq!(ops.iter().filter(|op| op.is("re")).count(), 2);
|
||||
assert_eq!(ops.iter().filter(|op| op.is("EMC")).count(), 2);
|
||||
assert!(figures(&reopened).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_isolates_shared_stream_occurrences_after_save() {
|
||||
assert_selected_occurrence_only(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_preserves_unique_stream_occurrences_after_save() {
|
||||
assert_selected_occurrence_only(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_leaves_another_page_using_the_stream_untouched() {
|
||||
let mut doc = tagged_figure_two_streams("", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
|
||||
let other_page = doc.add(Object::Dict(page));
|
||||
doc.update_dict(ObjRef::new(2, 0), |p| {
|
||||
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(other_page)]));
|
||||
p.set(b"Count".to_vec(), Object::Int(2));
|
||||
})
|
||||
.unwrap();
|
||||
let before = doc.get(ObjRef::new(10, 0)).clone();
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).unwrap();
|
||||
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
|
||||
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let pages = pdfcraft_model::pages(&reopened);
|
||||
assert_eq!(pages.len(), 2);
|
||||
let contents = pages[1].dict.get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(*reopened.resolve(&contents[0]), *before, "the other page's shared figure stream must stay intact");
|
||||
let (ops, skipped) = joined_ops(&reopened);
|
||||
assert_eq!(skipped, 0);
|
||||
assert!(ops.iter().any(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_keeps_text_commented_out_across_stream_boundary_hidden() {
|
||||
let mut doc = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream", HIDDEN_AND_AFTER);
|
||||
let result = autotag_text(&mut doc).expect("autotag succeeds");
|
||||
assert_eq!(result.pages, 1);
|
||||
assert_eq!(result.paragraphs, 2, "commented-out text at start of second stream must not be tagged");
|
||||
|
||||
let (ops, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(shown_strings(&ops), ["Visible", "After"]);
|
||||
|
||||
let p_tags: Vec<_> = tags(&doc).into_iter().filter(|t| t.kind == "P").collect();
|
||||
assert_eq!(p_tags.len(), 2);
|
||||
let texts: Vec<_> = p_tags.iter().flat_map(|t| tag_content(&doc, t.object).expect("tag content")).map(|item| item.text).collect();
|
||||
assert_eq!(texts, ["Visible", "After"]);
|
||||
|
||||
// Control: when the comment ends with a newline inside the first stream, Hidden is visible.
|
||||
let mut control = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream\n", HIDDEN_AND_AFTER);
|
||||
let control_res = autotag_text(&mut control).expect("control autotag succeeds");
|
||||
assert_eq!(control_res.paragraphs, 3);
|
||||
let (control_ops, _) = joined_ops(&control);
|
||||
assert_eq!(shown_strings(&control_ops), ["Visible", "Hidden", "After"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_preserves_shape_split_across_content_streams() {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50", "re f\nBT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
assert!(figures(&doc).is_empty());
|
||||
|
||||
let (ops, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0, "split rectangle operands must not be dropped as skipped tokens");
|
||||
let re_op = ops.iter().find(|op| op.is("re")).expect("rectangle operator must survive");
|
||||
assert_eq!(re_op.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
|
||||
assert_eq!(shown_strings(&ops), ["After"]);
|
||||
|
||||
// Control: rectangle whole in the first stream.
|
||||
let mut control = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
mark_decorative(&mut control, ObjRef::new(8, 0)).expect("control mark decorative succeeds");
|
||||
let (control_ops, control_skipped) = joined_ops(&control);
|
||||
assert_eq!(control_skipped, 0);
|
||||
let control_re = control_ops.iter().find(|op| op.is("re")).expect("control rectangle");
|
||||
assert_eq!(control_re.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
|
||||
assert_eq!(shown_strings(&control_ops), ["After"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_and_mark_artifact_keep_cross_stream_comment_hidden() {
|
||||
let mut doc = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
let (ops, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(shown_strings(&ops), ["After"], "commented-out Hidden text must not become visible after mark_decorative");
|
||||
|
||||
let mut doc_artifact = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
|
||||
mark_tag_artifact(&mut doc_artifact, ObjRef::new(8, 0)).expect("mark_tag_artifact succeeds");
|
||||
let (artifact_ops, artifact_skipped) = joined_ops(&doc_artifact);
|
||||
assert_eq!(artifact_skipped, 0);
|
||||
assert_eq!(shown_strings(&artifact_ops), ["After"], "commented-out Hidden text must not become visible after mark_tag_artifact");
|
||||
|
||||
// Checker also honours the cross-stream comment when scanning for untagged content.
|
||||
let mut comment_only = tagged_figure_two_streams("% a note at the end of the first stream", "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\n");
|
||||
let report = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(report.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
|
||||
mark_decorative(&mut comment_only, ObjRef::new(8, 0)).expect("mark decorative");
|
||||
let report_after = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(report_after.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
|
||||
}
|
||||
@@ -9,10 +9,12 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
|
||||
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`).
|
||||
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
|
||||
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
|
||||
A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as
|
||||
ISO 32000-2 Table 178 places them, the caption in black Helvetica 10.
|
||||
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
|
||||
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
|
||||
draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line;
|
||||
`appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
|
||||
draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic
|
||||
stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
|
||||
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
|
||||
flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction
|
||||
mark's overlay text, which becomes page content when applied; and a restyle or resize of a
|
||||
@@ -51,6 +53,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
|
||||
|
||||
|
||||
+529
-69
@@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String {
|
||||
smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect()
|
||||
}
|
||||
|
||||
/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow.
|
||||
pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000;
|
||||
|
||||
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
|
||||
/// half-circle bumps of about `r`, bulging outwards.
|
||||
pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String {
|
||||
let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum();
|
||||
// Outward normal of an edge: to the right of travel for counter-clockwise outlines.
|
||||
let sign = if area >= 0.0 { 1.0 } else { -1.0 };
|
||||
let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
|
||||
// Each edge rounds to at least one bump, so the total stays within the budget plus one per edge.
|
||||
let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64));
|
||||
let mut c = String::new();
|
||||
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
@@ -174,23 +180,42 @@ fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
|
||||
color(d, b"IC").flatten().or(fallback)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
/// 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 {
|
||||
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(" "))
|
||||
}
|
||||
|
||||
@@ -277,13 +302,14 @@ fn text_turn(d: &Dict) -> i64 {
|
||||
|
||||
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
|
||||
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
|
||||
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
|
||||
/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
|
||||
/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a
|
||||
/// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
|
||||
/// it can be drawn.
|
||||
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
|
||||
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
|
||||
let (what, text) = match d.name(b"Subtype")? {
|
||||
b"FreeText" => ("the comment's text", text(b"Contents")?),
|
||||
b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?),
|
||||
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
|
||||
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
|
||||
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
|
||||
@@ -293,6 +319,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`).
|
||||
@@ -315,6 +390,8 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let w = border_width(d);
|
||||
let mut res = Dict::new();
|
||||
let mut c = String::new();
|
||||
// Content after `c` that isn't UTF-8 (WinAnsi text).
|
||||
let mut tail: Vec<u8> = Vec::new();
|
||||
let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly");
|
||||
if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
@@ -439,13 +516,29 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
return None;
|
||||
}
|
||||
let fill = interior_color(d, Some(col)).unwrap_or(col);
|
||||
let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w);
|
||||
let [p, q] = layout.line;
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
|
||||
c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3])));
|
||||
for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) {
|
||||
for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) {
|
||||
c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1)));
|
||||
}
|
||||
c.push_str("[] 0 d\n");
|
||||
for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) {
|
||||
if !line_end(&mut c, end, tip, from, w) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some((text, m)) = layout.caption {
|
||||
// The caption is black, in the standard font the measurement label uses.
|
||||
tail.extend(
|
||||
format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5]))
|
||||
.bytes(),
|
||||
);
|
||||
// WinAnsi bytes go into the stream as-is, not through UTF-8.
|
||||
tail.extend(literal(&win_ansi(&text)));
|
||||
tail.extend_from_slice(b" Tj ET Q\n");
|
||||
res.set(b"Font".to_vec(), helvetica_resource());
|
||||
}
|
||||
}
|
||||
b"Polygon" | b"PolyLine" => {
|
||||
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?;
|
||||
@@ -506,7 +599,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",
|
||||
@@ -526,11 +620,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();
|
||||
@@ -559,7 +664,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
c.push_str("h\n");
|
||||
}
|
||||
}
|
||||
c.push_str("f*\n");
|
||||
c.push_str("f\n");
|
||||
return Some(form(rect, c.as_bytes(), res));
|
||||
}
|
||||
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
|
||||
@@ -593,7 +698,28 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
// Only PdfCraft's own Fill & Sign marks are drawn here.
|
||||
let name = d.name(b"Name")?;
|
||||
let col = stroke.unwrap_or([0.0; 3]);
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
// A mark or stamp placed on a turned page is drawn upright in its displayed box, then
|
||||
// mapped into `/Rect` the way the page maps display space into user space. Viewers
|
||||
// fit that box to `/Rect`; the page rotation then shows it the right way up.
|
||||
let turn = stamp_turn(d);
|
||||
let draw = if turn == 0 {
|
||||
rect
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
[0.0, 0.0, dw, dh]
|
||||
};
|
||||
let turned = |mut stream: Stream| {
|
||||
if turn != 0 {
|
||||
let m = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
stream
|
||||
};
|
||||
let [x0, y0, x1, y1] = draw;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let lw = w.min(h) * 0.12;
|
||||
match name {
|
||||
@@ -643,28 +769,13 @@ 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);
|
||||
let turn = stamp_turn(d);
|
||||
let draw = if turn == 0 {
|
||||
rect
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
[0.0, 0.0, dw, dh]
|
||||
};
|
||||
let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res);
|
||||
if turn != 0 {
|
||||
// Upright in the stamp's displayed box, then mapped into `/Rect` the way
|
||||
// the page maps display space into user space. Viewers fit that box to
|
||||
// `/Rect`; the page rotation then shows the label the right way up.
|
||||
let m = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
return Some(stream);
|
||||
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 {
|
||||
return Some(turned(form(draw, c.as_bytes(), res)));
|
||||
}
|
||||
}
|
||||
b"FreeText" => {
|
||||
let (text_color, size) = parse_da(d);
|
||||
@@ -762,14 +873,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()));
|
||||
@@ -781,6 +885,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
// PdfCraft measurement captions are kept separate from the comment's free-form text.
|
||||
// A restyle regenerates the path and its value together.
|
||||
let mut out = c.into_bytes();
|
||||
out.extend(tail);
|
||||
if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) {
|
||||
let value = value.to_text();
|
||||
if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") {
|
||||
@@ -792,22 +897,285 @@ 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))
|
||||
}
|
||||
|
||||
/// Size of a line's caption, as of a measurement's label.
|
||||
const CAPTION_SIZE: f64 = 10.0;
|
||||
|
||||
/// Characters of a line's caption drawn at most, as for a measurement's label.
|
||||
const MAX_CAPTION_CHARS: usize = 256;
|
||||
|
||||
/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches,
|
||||
/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow.
|
||||
const MAX_LINE_EXTENT: f64 = 14_400.0;
|
||||
|
||||
/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one.
|
||||
fn line_length(v: Option<f64>) -> f64 {
|
||||
v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT))
|
||||
}
|
||||
|
||||
/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the
|
||||
/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a
|
||||
/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption.
|
||||
fn line_caption(d: &Dict) -> Option<String> {
|
||||
if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") {
|
||||
return None;
|
||||
}
|
||||
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
|
||||
let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?;
|
||||
let words: Vec<&str> = raw.split_whitespace().collect();
|
||||
let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect();
|
||||
(!caption.is_empty()).then_some(caption)
|
||||
}
|
||||
|
||||
/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and
|
||||
/// numeric character references read, block ends read as spaces.
|
||||
fn rich_plain(rc: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut tag: Option<String> = None;
|
||||
// Rich text past this is not caption material; it bounds the work.
|
||||
for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) {
|
||||
match (&mut tag, ch) {
|
||||
(None, '<') => tag = Some(String::new()),
|
||||
(Some(name), '>') => {
|
||||
let name =
|
||||
name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase();
|
||||
if matches!(name.as_str(), "p" | "br" | "div" | "li") {
|
||||
out.push(' ');
|
||||
}
|
||||
tag = None;
|
||||
}
|
||||
(Some(name), c) => {
|
||||
if name.len() < 16 {
|
||||
name.push(c);
|
||||
}
|
||||
}
|
||||
(None, c) => out.push(c),
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
let mut rest = out.as_str();
|
||||
while let Some(at) = rest.find('&') {
|
||||
text.push_str(rest.get(..at).unwrap_or_default());
|
||||
let after = rest.get(at + 1..).unwrap_or_default();
|
||||
let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end)));
|
||||
let decoded = entity.and_then(|(name, end)| {
|
||||
let c = match name {
|
||||
"amp" => '&',
|
||||
"lt" => '<',
|
||||
"gt" => '>',
|
||||
"quot" => '"',
|
||||
"apos" => '\'',
|
||||
_ => {
|
||||
let num = name.strip_prefix('#')?;
|
||||
let code = match num.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
||||
None => num.parse().ok()?,
|
||||
};
|
||||
char::from_u32(code)?
|
||||
}
|
||||
};
|
||||
Some((c, end))
|
||||
});
|
||||
match decoded {
|
||||
Some((c, end)) => {
|
||||
text.push(c);
|
||||
rest = after.get(end + 1..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
text.push('&');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
text.push_str(rest);
|
||||
text
|
||||
}
|
||||
|
||||
/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178).
|
||||
#[derive(Debug, Default)]
|
||||
struct LineLayout {
|
||||
/// The line itself, start to end: `/L`, or `/L` moved out by its leader lines.
|
||||
line: [(f64, f64); 2],
|
||||
/// Strokes along the line: the whole line, or the two pieces either side of an inline caption.
|
||||
strokes: Vec<[(f64, f64); 2]>,
|
||||
/// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`.
|
||||
leaders: Vec<[(f64, f64); 2]>,
|
||||
/// A short stroke from the line to a caption `/CO` moved off it.
|
||||
tick: Option<[(f64, f64); 2]>,
|
||||
/// The caption and its text matrix.
|
||||
caption: Option<(String, [f64; 6])>,
|
||||
}
|
||||
|
||||
/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption
|
||||
/// entries.
|
||||
///
|
||||
/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L`
|
||||
/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn
|
||||
/// clockwise from the direction of travel; a negative one puts it on the right. The line sits
|
||||
/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0.
|
||||
///
|
||||
/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to
|
||||
/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line,
|
||||
/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h`
|
||||
/// along the line and `v` across it (positive: up in reading direction); a caption moved off an
|
||||
/// inline line gets a tick from the line to it.
|
||||
///
|
||||
/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption.
|
||||
/// Every point is finite; if anything derived from the entries is not, they are left out.
|
||||
fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout {
|
||||
let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() };
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let len = dx.hypot(dy);
|
||||
if !len.is_finite() || len <= 0.0 {
|
||||
return plain;
|
||||
}
|
||||
let (ux, uy) = (dx / len, dy / len);
|
||||
let mut out = LineLayout { line: [a, b], ..LineLayout::default() };
|
||||
let ll = line_length(d.get(b"LL").and_then(Object::as_f64));
|
||||
if ll != 0.0 {
|
||||
// The unit normal from `/L` towards the line: left of travel for a positive /LL.
|
||||
let s = ll.signum();
|
||||
let (nx, ny) = (-uy * s, ux * s);
|
||||
let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0);
|
||||
let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0);
|
||||
let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k);
|
||||
let off = ll.abs() + llo;
|
||||
out.line = [at(a, off), at(b, off)];
|
||||
out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]];
|
||||
}
|
||||
let [p, _] = out.line;
|
||||
let along = |t: f64| (p.0 + ux * t, p.1 + uy * t);
|
||||
let mut gap = None;
|
||||
if let Some(text) = line_caption(d) {
|
||||
let size = CAPTION_SIZE;
|
||||
let width = text_width(&text, size);
|
||||
// Reading direction, and "up" across it.
|
||||
let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) };
|
||||
let (vx, vy) = (-ty, tx);
|
||||
let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default();
|
||||
let h = line_length(co.first().copied());
|
||||
let v = line_length(co.get(1).copied());
|
||||
let top = d.name(b"CP") == Some(b"Top");
|
||||
// The caption's centre, as a distance along the line from `p`.
|
||||
let centre = len / 2.0 + h * (tx * ux + ty * uy);
|
||||
let mid = along(centre);
|
||||
// Baseline across the line: on top of it, or with the text centred on it.
|
||||
let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 };
|
||||
let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base);
|
||||
out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1]));
|
||||
if !top && v.abs() < size / 2.0 {
|
||||
let half = width / 2.0 + 2.0;
|
||||
gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len)));
|
||||
} else if !top {
|
||||
let reach = v - v.signum() * size * 0.4;
|
||||
out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]);
|
||||
}
|
||||
}
|
||||
out.strokes = match gap {
|
||||
Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(),
|
||||
None => vec![out.line],
|
||||
};
|
||||
let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite();
|
||||
let all_finite =
|
||||
out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite)
|
||||
&& out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite()));
|
||||
if all_finite { out } else { plain }
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -816,7 +1184,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!(
|
||||
@@ -882,16 +1250,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(
|
||||
@@ -964,14 +1331,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
|
||||
}
|
||||
+253
-36
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +159,14 @@ impl FillMark {
|
||||
FillMark::Line => "Line",
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred
|
||||
/// there and sized in displayed axes, so a line runs across the page as displayed (#2392).
|
||||
pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] {
|
||||
let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
|
||||
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
|
||||
}
|
||||
}
|
||||
|
||||
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
|
||||
@@ -386,7 +428,7 @@ pub enum Shape {
|
||||
by: Option<String>,
|
||||
},
|
||||
/// A typed signature or initials: filled outlines (each contour as points normalised to
|
||||
/// `rect`, 0–1, y up), drawn with the even-odd rule.
|
||||
/// `rect`, 0–1, y up), drawn with the non-zero winding rule.
|
||||
TypedSignature {
|
||||
rect: [f64; 4],
|
||||
contours: Vec<Vec<[f64; 2]>>,
|
||||
@@ -651,25 +693,65 @@ impl ReviewState {
|
||||
pub fn page_refs(doc: &Document) -> Result<Vec<ObjRef>, AnnotError> {
|
||||
let root = doc.root().ok_or(AnnotError::NoPageTree)?;
|
||||
let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?;
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut stack = vec![pages];
|
||||
while let Some(node) = stack.pop() {
|
||||
if !seen.insert(node) || seen.len() > 1_000_000 {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
match d.get(b"Kids").map(|k| doc.resolve(k)) {
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => {
|
||||
if let Some(a) = kids.as_array() {
|
||||
stack.extend(a.iter().rev().filter_map(|k| k.as_ref()));
|
||||
Ok(PageWalk::new(doc, pages).collect())
|
||||
}
|
||||
|
||||
/// 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, 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)], examined: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for PageWalk<'_> {
|
||||
type Item = ObjRef;
|
||||
|
||||
fn next(&mut self) -> Option<ObjRef> {
|
||||
while let Some((kids, next)) = self.pending.last_mut() {
|
||||
let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else {
|
||||
self.pending.pop();
|
||||
continue;
|
||||
};
|
||||
let node = kid.as_ref();
|
||||
*next += 1;
|
||||
self.examined += 1;
|
||||
let Some(node) = node else { continue };
|
||||
if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 {
|
||||
self.pending.clear();
|
||||
return None;
|
||||
}
|
||||
if !self.seen.insert(node) {
|
||||
continue;
|
||||
}
|
||||
let obj = self.doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
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),
|
||||
}
|
||||
_ => out.push(node),
|
||||
}
|
||||
None
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
|
||||
@@ -680,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.
|
||||
@@ -751,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 };
|
||||
@@ -803,6 +905,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
|
||||
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
|
||||
}
|
||||
|
||||
/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without
|
||||
/// the page's content or other annotations, so a render draws that annotation alone: to lay it
|
||||
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
|
||||
/// normal appearance (`/AP /N`) for that render to draw.
|
||||
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let has_normal = {
|
||||
let obj = doc.resolve(&entry);
|
||||
obj.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 !has_normal {
|
||||
return Ok(false);
|
||||
}
|
||||
set_annots(doc, p, vec![entry])?;
|
||||
doc.update_dict(p, |d| {
|
||||
d.remove(b"Contents");
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ── building ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Annotation flags (§12.5.3).
|
||||
@@ -898,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
|
||||
@@ -1081,6 +1204,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name(mark.name()));
|
||||
border(&mut d);
|
||||
// Drawn upright for the page as displayed, as a stamp is (#2392).
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
Shape::Stamp { stamp, by, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
@@ -1104,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));
|
||||
@@ -1355,8 +1483,22 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
|
||||
apd.remove(b"D");
|
||||
apd.remove(b"R");
|
||||
apd.set(b"N".to_vec(), Object::Ref(ap));
|
||||
// A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`.
|
||||
// The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with
|
||||
// no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing.
|
||||
let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
|
||||
.then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
|
||||
.flatten()
|
||||
.filter(|q| q.len() == 4 && finite(q))
|
||||
.map(|q| {
|
||||
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
|
||||
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
|
||||
});
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"AP".to_vec(), Object::Dict(apd));
|
||||
if let Some(rect) = icon_rect {
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
}
|
||||
d.remove(b"AS");
|
||||
pdfcraft_fonts::set_undrawn_mark(d, missing);
|
||||
})?;
|
||||
@@ -1433,8 +1575,15 @@ pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
|
||||
/// redrawing it. See [`AppearanceCache`] for when entries stay valid.
|
||||
pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option<Document> {
|
||||
let mut copy: Option<Document> = None;
|
||||
// An annotation listed more than once, on one page or several, is drawn once. `has_normal`
|
||||
// reads `doc`, which never gets the appearance, so every repeat used to add another stream to
|
||||
// the copy, and the display bytes grew with the number of listings.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for page in page_refs(doc).ok()? {
|
||||
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let d = annot_dict(doc, r);
|
||||
if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) {
|
||||
continue;
|
||||
@@ -1448,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();
|
||||
@@ -1774,6 +1930,48 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
|
||||
apply_text_box(doc, r, rect, meta, false)
|
||||
}
|
||||
|
||||
/// At most this many points reshape a comment (a drawn polygon has a handful).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
|
||||
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
|
||||
/// points with room for the line's width and endings, as when the comment was drawn, and the
|
||||
/// appearance is redrawn. A measurement keeps the points it measured.
|
||||
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.contains(b"Measure") {
|
||||
return Err(AnnotError::Invalid("a measurement's points are where it measured".into()));
|
||||
}
|
||||
if points.len() > MAX_POINTS {
|
||||
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
|
||||
}
|
||||
let style = Style { width: border_width_of(&d), ..Style::default() };
|
||||
let ends = line_endings_of(&d).unwrap_or_default();
|
||||
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
|
||||
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
|
||||
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
|
||||
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
|
||||
("Polygon", _) => {
|
||||
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
|
||||
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
|
||||
}
|
||||
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
|
||||
};
|
||||
// Checks the points too: finite, and far enough apart to draw.
|
||||
let rect = rect_for(&shape, &style)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(key.to_vec(), num_array(&points.concat()));
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
|
||||
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
|
||||
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
@@ -1987,13 +2185,12 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
|
||||
/// ovals, polygons and clouds, §12.5.6.8–9).
|
||||
/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6).
|
||||
pub fn fillable(subtype: &str) -> bool {
|
||||
matches!(subtype, "Square" | "Circle" | "Polygon")
|
||||
matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText")
|
||||
}
|
||||
|
||||
/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
|
||||
/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and
|
||||
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
|
||||
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -2001,7 +2198,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if !fillable(&subtype) {
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new fill.
|
||||
if appearance::build(&d).is_none() {
|
||||
@@ -2013,10 +2210,11 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" };
|
||||
match fill {
|
||||
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
|
||||
Some(c) => d.set(key.to_vec(), rgb(c)),
|
||||
None => {
|
||||
d.remove(b"IC");
|
||||
d.remove(key);
|
||||
}
|
||||
}
|
||||
touch(d, meta);
|
||||
@@ -2082,6 +2280,11 @@ 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]>,
|
||||
}
|
||||
|
||||
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
@@ -2136,6 +2339,12 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
}
|
||||
}
|
||||
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
|
||||
// Keep in step with `pdfcraft_render::inspect`.
|
||||
let points = match subtype.as_str() {
|
||||
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
|
||||
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||||
out.push(Summary {
|
||||
page,
|
||||
@@ -2155,6 +2364,8 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2205,7 +2416,7 @@ pub struct Props {
|
||||
pub subject: String,
|
||||
pub color: Option<Rgb>,
|
||||
pub opacity: f64,
|
||||
/// Border / line width, for shapes, lines and drawings.
|
||||
/// Border / line width, for shapes, lines, drawings and text boxes.
|
||||
pub width: Option<f64>,
|
||||
/// Notes only.
|
||||
pub icon: Option<NoteIcon>,
|
||||
@@ -2217,9 +2428,9 @@ pub struct Props {
|
||||
pub locked: bool,
|
||||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||||
pub endings: Option<Vec<LineEnding>>,
|
||||
/// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
|
||||
/// Whether the comment has a fill control (see [`fillable`]).
|
||||
pub fillable: bool,
|
||||
/// `/IC`, the interior colour (`None`: no fill).
|
||||
/// The interior colour (`None`: no fill).
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
@@ -2234,8 +2445,13 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||||
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
|
||||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine")
|
||||
.then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0));
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
|
||||
if subtype == "FreeText" {
|
||||
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
|
||||
} else {
|
||||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
|
||||
}
|
||||
});
|
||||
Some(Props {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
@@ -2254,9 +2470,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
|
||||
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
|
||||
fn fill_of(d: &Dict) -> Option<Rgb> {
|
||||
let c: Vec<f64> = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
|
||||
let c: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
let rgb = match c.as_slice() {
|
||||
[g] => [*g; 3],
|
||||
[r, g, b] => [*r, *g, *b],
|
||||
|
||||
+68
-12
@@ -78,11 +78,15 @@ pub struct LinkItem {
|
||||
pub style: LinkStyle,
|
||||
}
|
||||
|
||||
/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link
|
||||
/// lists as [`LinkAction::Other`].
|
||||
pub(crate) const MAX_URI_BYTES: usize = 16 << 20;
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
|
||||
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
|
||||
let dest_page = |dest: &Object| -> Option<usize> {
|
||||
let a = doc.resolve(dest);
|
||||
let first = a.as_array()?.first()?.clone();
|
||||
@@ -97,9 +101,20 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
|
||||
}
|
||||
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
|
||||
match a.name(b"S") {
|
||||
Some(b"URI") => LinkAction::Uri(
|
||||
a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(),
|
||||
),
|
||||
Some(b"URI") => {
|
||||
let uri = a.get(b"URI").map(|u| doc.resolve(u));
|
||||
let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]);
|
||||
// The raw length first, so an address already over the budget is never converted; then the
|
||||
// converted text, which is up to three times longer where the bytes are not UTF-8.
|
||||
let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes));
|
||||
match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) {
|
||||
Some((left, text)) => {
|
||||
*uri_budget = left;
|
||||
LinkAction::Uri(text.into_owned())
|
||||
}
|
||||
None => LinkAction::Other("web address too long".into()),
|
||||
}
|
||||
}
|
||||
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
|
||||
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
|
||||
None => LinkAction::Other("no action".into()),
|
||||
@@ -133,8 +148,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle {
|
||||
pub fn list(doc: &Document) -> Vec<LinkItem> {
|
||||
let Ok(pages) = page_refs(doc) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut uri_budget = MAX_URI_BYTES;
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
for (k, a) in annots(doc, *p).iter().enumerate() {
|
||||
// One annotation object listed under several entries or pages is one link.
|
||||
if let Some(r) = a.as_ref()
|
||||
&& !seen.insert(r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(d) = doc.resolve(a).as_dict().cloned() else { continue };
|
||||
if d.name(b"Subtype") != Some(b"Link") {
|
||||
continue;
|
||||
@@ -148,7 +171,7 @@ pub fn list(doc: &Document) -> Vec<LinkItem> {
|
||||
index: k,
|
||||
obj: a.as_ref(),
|
||||
rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])],
|
||||
action: action_of(doc, &d, &pages),
|
||||
action: action_of(doc, &d, &pages, &mut uri_budget),
|
||||
style: style_of(doc, &d),
|
||||
});
|
||||
}
|
||||
@@ -232,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()));
|
||||
@@ -244,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> {
|
||||
@@ -414,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)
|
||||
}
|
||||
+761
-2
@@ -991,6 +991,112 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() {
|
||||
assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged");
|
||||
}
|
||||
|
||||
/// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner
|
||||
/// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it
|
||||
/// to nothing in one with no width.
|
||||
#[test]
|
||||
fn a_note_without_an_appearance_is_shown_at_icon_size() {
|
||||
let 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 5 0 R 6 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [-23 240 480 468] /C [1 0 0] /Open true >>",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [310 380 310 392] /Name /Comment /F 4 >>",
|
||||
b"<< /Type /Annot /Subtype /FileAttachment /Rect [100 50 300 10] >>",
|
||||
]);
|
||||
let display = with_missing_appearances(&doc).expect("the notes get a display appearance");
|
||||
let rects = |doc: &Document| -> Vec<Vec<f64>> {
|
||||
let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::<Vec<f64>>());
|
||||
list(doc, 0).iter().map(|d| rect(d).unwrap_or_default()).collect()
|
||||
};
|
||||
assert_eq!(rects(&display), [vec![-23.0, 448.0, -3.0, 468.0], vec![310.0, 372.0, 330.0, 392.0], vec![100.0, 30.0, 120.0, 50.0]]);
|
||||
let original = [vec![-23.0, 240.0, 480.0, 468.0], vec![310.0, 380.0, 310.0, 392.0], vec![100.0, 50.0, 300.0, 10.0]];
|
||||
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];
|
||||
@@ -1293,14 +1399,36 @@ 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");
|
||||
}
|
||||
|
||||
/// A comment that `/Annots` lists many times, and that a second page lists too, is drawn once in
|
||||
/// the display copy. Every listing used to add another appearance stream, so a short array of
|
||||
/// repeated references multiplied the display bytes the engine writes on each refresh.
|
||||
#[test]
|
||||
fn a_comment_listed_many_times_is_drawn_once_for_display() {
|
||||
let first = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", vec!["5 0 R"; 1_000].join(" "));
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>",
|
||||
first.as_bytes(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>",
|
||||
]);
|
||||
let mut cache = AppearanceCache::default();
|
||||
let display = with_missing_appearances_cached(&doc, &mut cache).expect("the square needs an appearance");
|
||||
assert_eq!(display.object_numbers().len(), doc.object_numbers().len() + 1, "one appearance stream, not one per listing");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 0));
|
||||
assert!(list(&display, 0).iter().chain(&list(&display, 1)).all(|d| d.contains(b"AP")), "every listing shows it");
|
||||
with_missing_appearances_cached(&doc, &mut cache).expect("a refresh");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 1), "a refresh reuses the cached stream once");
|
||||
}
|
||||
|
||||
/// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box
|
||||
/// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused.
|
||||
#[test]
|
||||
@@ -1335,3 +1463,634 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() {
|
||||
assert_eq!(props(&doc, 0, i).unwrap().font_size, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_and_callouts_can_change_fill_and_border() {
|
||||
let mut doc = fixture();
|
||||
let shapes = [
|
||||
Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 },
|
||||
Shape::Callout {
|
||||
rect: [300.0, 600.0, 500.0, 660.0],
|
||||
knee: [260.0, 560.0],
|
||||
point: [200.0, 520.0],
|
||||
font_size: 12.0,
|
||||
ending: LineEnding::OpenArrow,
|
||||
},
|
||||
];
|
||||
for shape in shapes {
|
||||
let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
|
||||
assert!(props(&doc, 0, index).unwrap().fillable);
|
||||
set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
|
||||
set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
|
||||
let saved = reopen(&doc);
|
||||
let properties = props(&saved, 0, index).unwrap();
|
||||
assert_eq!(properties.fill, Some([1.0; 3]));
|
||||
assert_eq!(properties.width, Some(3.0));
|
||||
let ap = ap_content(&saved, &list(&saved, 0)[index]);
|
||||
assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}");
|
||||
set_fill(&mut doc, 0, index, None, &meta("test")).unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().fill, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_text_properties_show_legacy_or_absent_border_width() {
|
||||
let mut doc = fixture();
|
||||
let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap();
|
||||
let (_, reference) = annot_ref(&mut doc, 0, index).unwrap();
|
||||
doc.update_dict(reference, |d| {
|
||||
d.remove(b"BS");
|
||||
d.remove(b"Border");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0));
|
||||
doc.update_dict(reference, |d| {
|
||||
d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect()));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
|
||||
}
|
||||
|
||||
/// A line's ends and the corners of connected lines and polygons can be moved after drawing:
|
||||
/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is
|
||||
/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape.
|
||||
#[test]
|
||||
fn lines_and_polygons_are_reshaped_by_their_points() {
|
||||
let mut doc = fixture();
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
let before = ap_content(&doc, &list(&doc, 0)[l]);
|
||||
set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[l];
|
||||
let ends: Vec<f64> = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
|
||||
assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]);
|
||||
let r = rect(d);
|
||||
assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}");
|
||||
assert!(r[2] - r[0] < 40.0, "and no more: {r:?}");
|
||||
assert_ne!(ap_content(&doc2, d), before, "drawn anew");
|
||||
assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]);
|
||||
// Two ends exactly, far enough apart.
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
// A polygon's corner.
|
||||
let g = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }),
|
||||
&meta("g"),
|
||||
)
|
||||
.unwrap();
|
||||
set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[g];
|
||||
let v: Vec<f64> = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
|
||||
assert_eq!(v.len(), 8);
|
||||
assert!(rect(d)[2] > 450.0);
|
||||
// Locked: refused, and nothing changes.
|
||||
set_locked(&mut doc, 0, l, true).unwrap();
|
||||
assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err());
|
||||
// Other comments have no points to move.
|
||||
let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap();
|
||||
assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
|
||||
let mut doc = fixture();
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 });
|
||||
// A cycle back to the root, then both pages 128 times over.
|
||||
let mut kids = vec![Object::Ref(root)];
|
||||
kids.extend([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(256));
|
||||
doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();
|
||||
let mut walk = PageWalk::new(&doc, root);
|
||||
assert_eq!(walk.next(), Some(first));
|
||||
assert!(walk.pending.len() <= 2, "repeated kids retained {} pending entries", walk.pending.len());
|
||||
assert_eq!(walk.next(), Some(second));
|
||||
assert_eq!(walk.next(), None);
|
||||
assert_eq!(page_refs(&doc).unwrap(), [first, second]);
|
||||
}
|
||||
|
||||
/// #1786: TrueType glyph contours (such as Inter's composite Ђ and ћ) overlap with the same
|
||||
/// winding direction where strokes cross, so typed signature appearances must fill with the
|
||||
/// non-zero winding operator (`f`), not even-odd (`f*`).
|
||||
#[test]
|
||||
fn typed_signature_uses_nonzero_winding_fill() {
|
||||
let mut doc = fixture();
|
||||
let contours = vec![vec![[0.4, 0.0], [0.6, 0.0], [0.6, 1.0], [0.4, 1.0]], vec![[0.0, 0.7], [1.0, 0.7], [1.0, 0.85], [0.0, 0.85]]];
|
||||
let idx = add_annotation(&mut doc, &new(0, Shape::TypedSignature { rect: [100.0, 100.0, 200.0, 140.0], contours }), &meta("sig")).unwrap();
|
||||
let ap = ap_content(&doc, &list(&doc, 0)[idx]);
|
||||
assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}");
|
||||
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();
|
||||
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());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_listing_skips_repeated_refs_and_caps_web_address_text() {
|
||||
// One link referenced 1000 times, and 300 small links sharing one 64 KiB address.
|
||||
let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300));
|
||||
let doc = doc_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(),
|
||||
"<< /S /URI /URI 6 0 R >>".into(),
|
||||
format!("({})", "u".repeat(64 << 10)),
|
||||
]);
|
||||
let all = links::list(&doc);
|
||||
assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1);
|
||||
assert_eq!(all.len(), 301, "every distinct link is still listed");
|
||||
let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
|
||||
assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
|
||||
assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_address_cap_counts_the_text_kept_after_conversion() {
|
||||
// 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a
|
||||
// three-byte replacement character.
|
||||
let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200);
|
||||
let doc = doc_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
|
||||
"<< /S /URI /URI 5 0 R >>".into(),
|
||||
format!("<{}>", "FF".repeat(64 << 10)),
|
||||
]);
|
||||
let all = links::list(&doc);
|
||||
assert_eq!(all.len(), 200);
|
||||
let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
|
||||
assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
|
||||
assert!(kept > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_outline_has_a_bump_budget() {
|
||||
// 50 long edges would each ask for 10,000 bumps of radius 1.
|
||||
let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect();
|
||||
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());
|
||||
}
|
||||
|
||||
// ── Line annotations: leader lines and captions (ISO 32000-2 §12.5.6.7, Table 178) ──────────
|
||||
|
||||
/// A red Line from `l` with a 1 pt border, a generous `/Rect`, and `extra` entries.
|
||||
fn table_178_line(l: [f64; 4], extra: &[(&str, Object)]) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Line"));
|
||||
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 600.0, 800.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()));
|
||||
d.set(b"L".to_vec(), Object::Array(l.map(Object::Real).to_vec()));
|
||||
for (k, v) in extra {
|
||||
d.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The built appearance's content.
|
||||
fn line_drawing(d: &Dict) -> String {
|
||||
let s = crate::appearance::build(d).expect("the line is drawn");
|
||||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// Every straight two-point stroke (`x0 y0 m x1 y1 l S`) in a drawing.
|
||||
fn segments(content: &str) -> Vec<[f64; 4]> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
if t.len() != 7 || t[2] != "m" || t[5] != "l" || t[6] != "S" {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<f64> = [t[0], t[1], t[3], t[4]].iter().map(|x| x.parse().unwrap()).collect();
|
||||
Some([v[0], v[1], v[2], v[3]])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The caption's text matrix (`a b c d e f Tm`), if any.
|
||||
fn caption_matrix(content: &str) -> Option<[f64; 6]> {
|
||||
let at = content.find(" Tm")?;
|
||||
let before: Vec<f64> = content[..at].split_whitespace().rev().take(6).map(|x| x.parse().unwrap()).collect();
|
||||
Some([before[5], before[4], before[3], before[2], before[1], before[0]])
|
||||
}
|
||||
|
||||
fn has(segs: &[[f64; 4]], want: [f64; 4]) -> bool {
|
||||
segs.iter().any(|s| s.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positive_leader_length_puts_the_line_above_a_left_to_right_line_and_a_negative_one_below() {
|
||||
let up = segments(&line_drawing(&table_178_line([72.0, 620.0, 272.0, 620.0], &[("LL", Object::Real(20.0))])));
|
||||
assert!(has(&up, [72.0, 640.0, 272.0, 640.0]), "the line is 20 above /L: {up:?}");
|
||||
assert!(has(&up, [72.0, 620.0, 72.0, 640.0]) && has(&up, [272.0, 620.0, 272.0, 640.0]), "leader lines from /L up to it: {up:?}");
|
||||
assert!(!has(&up, [72.0, 620.0, 272.0, 620.0]), "nothing along /L itself: {up:?}");
|
||||
|
||||
let down = segments(&line_drawing(&table_178_line([72.0, 540.0, 272.0, 540.0], &[("LL", Object::Real(-20.0)), ("LLE", Object::Real(10.0))])));
|
||||
assert!(has(&down, [72.0, 520.0, 272.0, 520.0]), "the line is 20 below /L: {down:?}");
|
||||
assert!(has(&down, [72.0, 540.0, 72.0, 510.0]) && has(&down, [272.0, 540.0, 272.0, 510.0]), "leaders run 10 past it: {down:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leader_offset_leaves_a_gap_and_the_endings_sit_on_the_line_itself() {
|
||||
let d = table_178_line(
|
||||
[72.0, 460.0, 272.0, 460.0],
|
||||
&[("LL", Object::Real(20.0)), ("LLO", Object::Real(10.0)), ("LE", Object::Array(vec![Object::name("OpenArrow"), Object::name("OpenArrow")]))],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
let segs = segments(&content);
|
||||
assert!(has(&segs, [72.0, 490.0, 272.0, 490.0]), "the line sits |LL| + LLO above /L: {segs:?}");
|
||||
assert!(has(&segs, [72.0, 470.0, 72.0, 490.0]) && has(&segs, [272.0, 470.0, 272.0, 490.0]), "leaders start LLO from /L: {segs:?}");
|
||||
// The open arrows' tips (`tip l`) are at the ends of the line, not of /L.
|
||||
assert!(content.contains(" 72 490 l ") && content.contains(" 272 490 l "), "arrows at the moved line: {content}");
|
||||
assert!(!content.contains(" 72 460 l "), "no arrow on /L: {content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_caption_breaks_the_line_around_its_text() {
|
||||
let d =
|
||||
table_178_line([72.0, 380.0, 272.0, 380.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("Inline 50 mm")))]);
|
||||
let content = line_drawing(&d);
|
||||
assert!(content.contains("(Inline 50 mm) Tj"), "the caption is drawn: {content}");
|
||||
assert!(content.contains("/Helv 10 Tf"), "in Helvetica 10: {content}");
|
||||
let m = caption_matrix(&content).expect("text matrix");
|
||||
let width = crate::appearance::text_width("Inline 50 mm", 10.0);
|
||||
assert!((m[4] - (172.0 - width / 2.0)).abs() < 0.01 && m[5] < 380.0 && m[5] > 380.0 - 10.0, "centred on the line: {m:?}");
|
||||
let segs = segments(&content);
|
||||
assert_eq!(segs.len(), 2, "two pieces: {segs:?}");
|
||||
assert!(segs.iter().all(|s| s[2] <= 172.0 - width / 2.0 || s[0] >= 172.0 + width / 2.0), "no stroke under the text: {segs:?}");
|
||||
assert!(has(&segs, [72.0, 380.0, 172.0 - width / 2.0 - 2.0, 380.0]), "the left piece starts at the line's start: {segs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_caption_sits_above_the_unbroken_line() {
|
||||
let d = table_178_line(
|
||||
[72.0, 300.0, 272.0, 300.0],
|
||||
&[("Cap", Object::Bool(true)), ("CP", Object::name("Top")), ("Contents", Object::String(PdfString::text("Top 50 mm")))],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
let m = caption_matrix(&content).expect("text matrix");
|
||||
assert!(m[5] > 300.0 && m[5] < 305.0, "baseline just above the line: {m:?}");
|
||||
assert_eq!(segments(&content), vec![[72.0, 300.0, 272.0, 300.0]], "the line is whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caption_offset_moves_it_along_and_off_the_line_with_a_tick() {
|
||||
let co = |h: f64, v: f64| Object::Array(vec![Object::Real(h), Object::Real(v)]);
|
||||
let text = ("Contents", Object::String(PdfString::text("Offset 15")));
|
||||
let base = caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), text.clone()]))).unwrap();
|
||||
let content = line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(0.0, 15.0)), text.clone()]));
|
||||
let up = caption_matrix(&content).unwrap();
|
||||
assert!((up[5] - base[5] - 15.0).abs() < 0.01 && (up[4] - base[4]).abs() < 0.01, "15 up: {base:?} → {up:?}");
|
||||
let segs = segments(&content);
|
||||
assert!(has(&segs, [330.0, 700.0, 530.0, 700.0]), "a caption off the line leaves it whole: {segs:?}");
|
||||
assert!(segs.iter().any(|s| s[0] == 430.0 && s[2] == 430.0 && s[1] == 700.0 && s[3] > 705.0), "a tick from the midpoint up: {segs:?}");
|
||||
let along =
|
||||
caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(-30.0, 0.0)), text])))
|
||||
.unwrap();
|
||||
assert!((along[4] - base[4] + 30.0).abs() < 0.01 && (along[5] - base[5]).abs() < 0.01, "30 left: {base:?} → {along:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_cap_true_no_caption_is_drawn() {
|
||||
for cap in [None, Some(Object::Bool(false)), Some(Object::Int(1))] {
|
||||
let mut extra = vec![("Contents", Object::String(PdfString::text("Hidden caption")))];
|
||||
if let Some(c) = cap.clone() {
|
||||
extra.push(("Cap", c));
|
||||
}
|
||||
let d = table_178_line([330.0, 620.0, 530.0, 620.0], &extra);
|
||||
let content = line_drawing(&d);
|
||||
assert!(!content.contains("Tj"), "{cap:?}: {content}");
|
||||
assert_eq!(segments(&content), vec![[330.0, 620.0, 530.0, 620.0]]);
|
||||
assert!(crate::appearance::undrawable_text(&d).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagonal_lines_leaders_are_perpendicular_and_its_caption_turns_with_it() {
|
||||
let d = table_178_line(
|
||||
[330.0, 460.0, 530.0, 560.0],
|
||||
&[
|
||||
("LL", Object::Real(20.0)),
|
||||
("LLE", Object::Real(10.0)),
|
||||
("Cap", Object::Bool(true)),
|
||||
("Contents", Object::String(PdfString::text("Diagonal"))),
|
||||
],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
let segs = segments(&content);
|
||||
let (ux, uy) = (200.0 / 100f64.hypot(200.0), 100.0 / 100f64.hypot(200.0));
|
||||
let leaders: Vec<&[f64; 4]> = segs.iter().filter(|s| (s[2] - s[0]) * ux + (s[3] - s[1]) * uy < 1e-2).collect();
|
||||
assert_eq!(leaders.len(), 2, "two leader lines across the line: {segs:?}");
|
||||
for l in leaders {
|
||||
let (dx, dy) = (l[2] - l[0], l[3] - l[1]);
|
||||
assert!((dx.hypot(dy) - 30.0).abs() < 0.01, "|LL| + LLE long: {l:?}");
|
||||
assert!(dx < 0.0 && dy > 0.0, "up and to the left (left of travel): {l:?}");
|
||||
}
|
||||
let m = caption_matrix(&content).expect("caption");
|
||||
assert!((m[0] - ux).abs() < 1e-3 && (m[1] - uy).abs() < 1e-3, "the text runs along the line: {m:?}");
|
||||
// Drawn right to left, it still reads left to right.
|
||||
let back = caption_matrix(&line_drawing(&table_178_line(
|
||||
[530.0, 560.0, 330.0, 460.0],
|
||||
&[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("x")))],
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(back[0] > 0.0, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caption_from_rich_text_and_one_the_font_cant_draw() {
|
||||
let rc = "<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\"><p>Dim & <b>5</b>0 mm <x></p></body>";
|
||||
let d = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("RC", Object::String(PdfString::text(rc)))]);
|
||||
let s = crate::appearance::build(&d).unwrap().decoded().unwrap();
|
||||
assert!(s.windows(17).any(|w| w == b"(Dim & 50 mm <x>)"), "{}", String::from_utf8_lossy(&s));
|
||||
let cjk = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("長さ")))]);
|
||||
assert_eq!(crate::appearance::undrawable_text(&cjk), Some(("the line's caption", '長')));
|
||||
// A PdfCraft measurement's label is its caption: the comment text isn't drawn a second time.
|
||||
let mut measured = cjk.clone();
|
||||
measured.set(b"PCMeasureValue".to_vec(), Object::String(PdfString::text("5 mm")));
|
||||
assert_eq!(crate::appearance::undrawable_text(&measured), None);
|
||||
let content = line_drawing(&measured);
|
||||
assert_eq!(content.matches(" Tj").count(), 1, "{content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
|
||||
let cap = ("Cap", Object::Bool(true));
|
||||
let text = ("Contents", Object::String(PdfString::text(&"w".repeat(10_000))));
|
||||
// A zero-length line has no direction: no leader lines, no caption, no NaN.
|
||||
let point =
|
||||
line_drawing(&table_178_line([50.0, 50.0, 50.0, 50.0], &[("LL", Object::Real(20.0)), ("LLE", Object::Real(5.0)), cap.clone(), text.clone()]));
|
||||
assert!(!point.contains("NaN") && !point.contains("inf") && !point.contains("Tj"), "{point}");
|
||||
assert_eq!(segments(&point).len(), 1);
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MAX] {
|
||||
let d = table_178_line(
|
||||
[0.0, 0.0, 100.0, 0.0],
|
||||
&[
|
||||
("LL", Object::Real(bad)),
|
||||
("LLE", Object::Real(bad)),
|
||||
("LLO", Object::Real(bad)),
|
||||
("CO", Object::Array(vec![Object::Real(bad), Object::Real(bad)])),
|
||||
cap.clone(),
|
||||
text.clone(),
|
||||
],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
assert!(!content.contains("NaN") && !content.contains("inf"), "{bad}: {content}");
|
||||
for s in segments(&content) {
|
||||
assert!(s.iter().all(|x| x.abs() <= 50_000.0), "{bad}: {s:?}");
|
||||
}
|
||||
}
|
||||
// Negative LLE and LLO count as 0.
|
||||
let neg = segments(&line_drawing(&table_178_line(
|
||||
[0.0, 0.0, 100.0, 0.0],
|
||||
&[("LL", Object::Real(10.0)), ("LLE", Object::Real(-5.0)), ("LLO", Object::Real(-5.0))],
|
||||
)));
|
||||
assert!(has(&neg, [0.0, 0.0, 0.0, 10.0]) && has(&neg, [0.0, 10.0, 100.0, 10.0]), "{neg:?}");
|
||||
// Huge endpoints whose midpoint would overflow fall back to the plain line.
|
||||
let huge = line_drawing(&table_178_line([f64::MAX, f64::MAX, f64::MAX / 2.0, f64::MAX], &[("LL", Object::Real(20.0)), cap, text]));
|
||||
assert!(!huge.contains("NaN") && !huge.contains("inf"), "{huge}");
|
||||
// The caption is cut to 256 characters.
|
||||
let long = line_drawing(&table_178_line(
|
||||
[0.0, 0.0, 100.0, 0.0],
|
||||
&[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text(&"w".repeat(10_000))))],
|
||||
));
|
||||
assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256");
|
||||
assert!(!long.contains(&"w".repeat(257)));
|
||||
}
|
||||
@@ -10,7 +10,7 @@ It reports two kinds of finding:
|
||||
|
||||
- **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough
|
||||
against the text's colour (WCAG contrast below 3) to hide it, while the text is still
|
||||
extractable underneath;
|
||||
extractable underneath; opaque filled Square annotations count as covers too;
|
||||
- **unapplied mark**: a Redact annotation that was marked but never applied.
|
||||
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! Positions compare lexicographically in paint order.
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse};
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
use crate::AuditError;
|
||||
|
||||
@@ -109,6 +109,9 @@ struct Walker<'a> {
|
||||
pieces: &'a Pieces,
|
||||
out: Vec<FilledRect>,
|
||||
ops_seen: usize,
|
||||
/// Decoded bytes and form entries left for the page, its forms included: spending either
|
||||
/// fails the audit.
|
||||
budget: DecodeBudget,
|
||||
}
|
||||
|
||||
impl<'a> Walker<'a> {
|
||||
@@ -144,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;
|
||||
@@ -258,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
|
||||
@@ -271,8 +275,11 @@ impl<'a> Walker<'a> {
|
||||
})
|
||||
.unwrap_or(Matrix::IDENTITY);
|
||||
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
if !self.budget.enter_form() {
|
||||
return Err(AuditError::TooManyForms);
|
||||
}
|
||||
// A form stream that cannot be decoded fails the audit: it might hide a cover.
|
||||
let data = stream.decoded_strict()?;
|
||||
let data = self.budget.decode_strict(stream)?;
|
||||
let ops = parse(&data).ops;
|
||||
// The XObject matrix maps form space into the user space active at `Do`.
|
||||
let mut sub_gs = gs.clone();
|
||||
@@ -304,7 +311,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
|
||||
/// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint
|
||||
/// positions. A page's content streams are walked once, joined, with one graphics state, so
|
||||
/// state set in an earlier stream is still in force in the later ones. Any content stream that
|
||||
/// cannot be decoded is an audit failure, never an empty result.
|
||||
/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`]
|
||||
/// in all, is an audit failure, never an empty result.
|
||||
pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, AuditError> {
|
||||
let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let contents: Vec<Object> = match page.get(b"Contents") {
|
||||
@@ -314,15 +322,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
let mut budget = DecodeBudget::page();
|
||||
let mut streams = Vec::with_capacity(contents.len());
|
||||
for c in &contents {
|
||||
if let Object::Stream(s) = &*doc.resolve(c) {
|
||||
streams.push(s.decoded_strict()?);
|
||||
streams.push(budget.decode_strict(s)?);
|
||||
}
|
||||
}
|
||||
let pieces = Pieces::join(&streams);
|
||||
let parsed = pieces.parse();
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 };
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
|
||||
let mut gs = Gs::default();
|
||||
let mut stack = Vec::new();
|
||||
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
|
||||
|
||||
+205
-40
@@ -12,8 +12,9 @@
|
||||
//! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l`
|
||||
//! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are
|
||||
//! considered when closed by a fill operator — other filled paths, shadings and patterns are
|
||||
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation
|
||||
//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign;
|
||||
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; opaque
|
||||
//! filled `/Square` annotations count as covers, but annotation rectangles are compared in
|
||||
//! unrotated user space, so pages with `/Rotate` may misalign;
|
||||
//! covers inside form XObjects are found, but only text drawn directly in the page content
|
||||
//! is checked under them, so text inside a form XObject is not audited.
|
||||
//!
|
||||
@@ -64,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,
|
||||
}
|
||||
|
||||
@@ -76,8 +77,14 @@ pub enum AuditError {
|
||||
TooManyOps,
|
||||
#[error("form XObjects nest deeper than the audit follows")]
|
||||
TooDeep,
|
||||
#[error("content walk entered more form XObjects than the audit follows")]
|
||||
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)]
|
||||
@@ -94,39 +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() {
|
||||
let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?;
|
||||
// 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. Redact annotations that were never applied.
|
||||
// 2. Annotation covers over extractable text.
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if covers(&annot, 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::UnappliedMark, rect: annot, covered_text: covered });
|
||||
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),
|
||||
};
|
||||
let covered_text = budget.report_text(covered, copying)?;
|
||||
out.push(AuditFinding { page: n, kind, rect, covered_text });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,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:
|
||||
@@ -170,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)
|
||||
@@ -191,9 +311,14 @@ fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots`
|
||||
/// means no marks; a malformed one fails the audit rather than silently passing.
|
||||
fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
|
||||
enum AnnotationCover {
|
||||
Redact([f64; 4]),
|
||||
Square { rect: [f64; 4], rgb: [f64; 3] },
|
||||
}
|
||||
|
||||
/// Opaque annotation covers on a page (unrotated user space). A missing `/Annots` means no
|
||||
/// covers; a malformed one fails the audit rather than silently passing.
|
||||
fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditError> {
|
||||
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
|
||||
let resolved = doc.resolve(a);
|
||||
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
|
||||
@@ -207,16 +332,56 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
|
||||
pdfcraft_cos::Object::Dict(d) => d.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
if dict.name(b"Subtype") != Some(b"Redact") {
|
||||
continue;
|
||||
}
|
||||
let v: Vec<f64> = match dict.get(b"Rect") {
|
||||
None => continue,
|
||||
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
|
||||
};
|
||||
if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
|
||||
out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]);
|
||||
if v.len() != 4 || !v.iter().all(|x| x.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
|
||||
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);
|
||||
if flags & 2 != 0 || flags & 32 != 0 {
|
||||
continue;
|
||||
}
|
||||
let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0);
|
||||
if alpha >= 1.0
|
||||
&& let Some(rgb) = annotation_color(doc, &dict, b"IC")
|
||||
{
|
||||
out.push(AnnotationCover::Square { rect, rgb });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
return None;
|
||||
}
|
||||
match values.as_slice() {
|
||||
[g] => Some([g.clamp(0.0, 1.0); 3]),
|
||||
[r, g, b] => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
|
||||
[c, m, y, k] => {
|
||||
let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0));
|
||||
Some([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,69 @@ fn a_box_grazing_a_word_is_not_flagged() {
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_filled_square_annotation_over_text_is_found() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
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(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
assert_eq!(findings[0].rect, [95.0, 685.0, 325.0, 725.0]);
|
||||
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
||||
}
|
||||
|
||||
/// A Hidden Square (`/F 2`) is never drawn, so the text under it is not a finding.
|
||||
#[test]
|
||||
fn hidden_square_annotation_is_not_a_finding() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
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(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 2 >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
/// A NoView Square (`/F 32`) is never drawn on screen, so the text under it is not a finding.
|
||||
#[test]
|
||||
fn no_view_square_annotation_is_not_a_finding() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
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(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 32 >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
/// A Print-only Square (`/F 4`) is still drawn on screen, so the text under it is a finding.
|
||||
#[test]
|
||||
fn print_flag_square_annotation_is_still_a_finding() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
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(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 4 >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_before_text_is_not_flagged() {
|
||||
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
||||
@@ -529,3 +592,231 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
|
||||
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn a_cover_over_text_with_empty_or_substitute_actual_text_is_found() {
|
||||
for actual in ["()", "(nothing here)"] {
|
||||
let content =
|
||||
format!("/Span << /ActualText {actual} >> BDC\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nEMC\n0 g\n100 688 220 32 re f\n");
|
||||
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{actual}: {findings:?}");
|
||||
assert!(findings[0].covered_text.contains("SECRET"), "{actual}: covered_text was {:?}", findings[0].covered_text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_stream_references_share_one_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
// One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents,
|
||||
// and by 300 draws of a form holding it.
|
||||
let mut doc = Document::new_empty();
|
||||
let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect();
|
||||
assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams");
|
||||
|
||||
let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
|
||||
let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20]));
|
||||
let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes()));
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
|
||||
let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
|
||||
assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_entries_past_the_page_allowance_fail_the_audit() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
// An empty form decodes to no bytes, so only the count of entries bounds drawing it again.
|
||||
let mut doc = Document::new_empty();
|
||||
let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
|
||||
let form = doc.add(Stream::from_raw(form_dict, Vec::new()));
|
||||
let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes()));
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
|
||||
let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
|
||||
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
|
||||
}
|
||||
@@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
png = "0.18"
|
||||
base64 = { version = "0.22", optional = true }
|
||||
|
||||
|
||||
@@ -28,7 +27,11 @@ mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
png = "0.18"
|
||||
flate2.workspace = true
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
rustix = { version = "1", features = ["fs"] }
|
||||
@@ -15,6 +15,12 @@ tools() -> Vec<ToolDef> // name, title, descrip
|
||||
mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JSON-RPC 2.0
|
||||
```
|
||||
|
||||
`content_update.rotate` rotates an added image counter-clockwise by an integer multiple of
|
||||
90 degrees. Large positive and negative signed 64-bit angles are normalized before combining
|
||||
them with the image's current orientation. Full turns retain its appearance, rectangle, crop
|
||||
and flips. Angles that are not multiples of 90 are refused without changing the document or
|
||||
its undo/redo history.
|
||||
|
||||
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
|
||||
|
||||
## Conventions
|
||||
@@ -27,7 +33,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
|
||||
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
|
||||
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss.
|
||||
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
|
||||
- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root <root>: <path>`. It applies on top of `with_root`.
|
||||
- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root <root>: <path>`. As with a root set, on Windows a path on another network share or device path is refused with that refusal without being contacted (#4003). It applies on top of `with_root`.
|
||||
|
||||
## Text search and cache limits
|
||||
|
||||
@@ -122,7 +128,7 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
|
||||
1. Add the engine capability first, with its tests (the tool is a thin adapter).
|
||||
2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists.
|
||||
3. Handle it in `Automation::call`, validating pages and positions with the existing helpers.
|
||||
4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
|
||||
4. Add an end-to-end test in `tests/it/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
|
||||
|
||||
|
||||
## Moving several page objects
|
||||
|
||||
+101
-13
@@ -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,21 +639,29 @@ 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() };
|
||||
let id = self.doc(a)?.id;
|
||||
let doc = self.doc(a)?;
|
||||
// Recognizing needs only permission to change the document; the words it found are
|
||||
// returned only when copying is allowed too.
|
||||
let (id, copy) = (doc.id, doc.allows_copying());
|
||||
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|p| match &p.skipped {
|
||||
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
|
||||
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
|
||||
None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
|
||||
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
|
||||
})
|
||||
.collect();
|
||||
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();
|
||||
@@ -646,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 {
|
||||
@@ -657,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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,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> {
|
||||
@@ -696,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)?;
|
||||
@@ -710,9 +770,19 @@ impl Automation {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
|
||||
let bytes = self.doc_to_copy(a)?.export_office(format);
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
|
||||
let export = self.doc_to_copy(a)?.export_office_checked(format);
|
||||
write_atomic(&path, &export.bytes)?;
|
||||
// Text left out to keep a hostile page bounded is said, not silent (#3120).
|
||||
let truncated: Vec<usize> = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(),
|
||||
"truncated": !truncated.is_empty(), "truncated_pages": truncated });
|
||||
if !export.truncated_pages.is_empty() {
|
||||
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
out["warnings"] = json!([format!(
|
||||
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
|
||||
)]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pdfa_level(&self, a: &Args) -> Result<pdfcraft_engine::pdfa::Level> {
|
||||
@@ -831,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) => {
|
||||
@@ -1028,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:
|
||||
@@ -173,8 +171,7 @@ impl Automation {
|
||||
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
|
||||
let all = self.comments(a)?;
|
||||
let view = |c: &Annotation| -> Value {
|
||||
let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))];
|
||||
json!({
|
||||
let mut v = json!({
|
||||
"id": c.name,
|
||||
"page": c.page + 1,
|
||||
"index": c.index + 1,
|
||||
@@ -182,9 +179,19 @@ impl Automation {
|
||||
"author": c.author,
|
||||
"contents": c.contents,
|
||||
"modified": c.modified,
|
||||
"rect": rect_to_view(p, 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
|
||||
// comment_edit's `points` takes them.
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
if !c.points.is_empty() {
|
||||
let round = |v: f32| (v * 100.0).round() / 100.0;
|
||||
let page = doc.info.pages.get(c.page);
|
||||
v["points"] =
|
||||
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
|
||||
}
|
||||
v
|
||||
};
|
||||
let roots: Vec<Value> = all
|
||||
.iter()
|
||||
@@ -375,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)
|
||||
@@ -551,8 +558,7 @@ impl Automation {
|
||||
"line" => FillMark::Line,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))),
|
||||
};
|
||||
let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
(Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new())
|
||||
(Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new())
|
||||
}
|
||||
};
|
||||
let style = Style::default_for(&shape);
|
||||
@@ -589,7 +595,10 @@ impl Automation {
|
||||
pub(crate) fn comments_hide(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let hidden = a.opt_bool("hidden")?.unwrap_or(true);
|
||||
self.session.set_hide_comments(id, hidden);
|
||||
if self.session.set_hide_comments(id, hidden) {
|
||||
// The cached renderer drew with the old setting; page_render must read it again.
|
||||
self.renderers.remove(&id);
|
||||
}
|
||||
Ok(json!({ "hidden": hidden }))
|
||||
}
|
||||
|
||||
@@ -626,14 +635,34 @@ 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));
|
||||
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
|
||||
}
|
||||
if let Some(pts) = a.get("points") {
|
||||
let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into());
|
||||
let points = pts
|
||||
.as_array()
|
||||
.ok_or_else(wrong)?
|
||||
.iter()
|
||||
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
|
||||
_ => Err(wrong()),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
edits.push(Edit::ReshapeAnnotation { page, index, points });
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into()));
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
|
||||
));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
self.apply(a, edit)
|
||||
@@ -647,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(())
|
||||
}
|
||||
|
||||
@@ -73,9 +79,9 @@ impl Automation {
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let index = per_page.entry(it.page).or_default();
|
||||
let ph = doc.info.pages[it.page].height as f64;
|
||||
let mut v =
|
||||
json!({ "page": it.page + 1, "index": *index + 1, "rect": to_view(it.content.rect(), ph).map(|x| (x * 100.0).round() / 100.0) });
|
||||
// Unchanged on a page the viewer could not read (#2660), like `crate::view_rect`.
|
||||
let rect = doc.info.pages.get(it.page).map_or(it.content.rect(), |p| to_view(it.content.rect(), p.height as f64));
|
||||
let mut v = json!({ "page": it.page + 1, "index": *index + 1, "rect": rect.map(|x| (x * 100.0).round() / 100.0) });
|
||||
*index += 1;
|
||||
match &it.content {
|
||||
AddedContent::Text(t) => {
|
||||
@@ -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,14 +171,15 @@ 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 {
|
||||
return Err(bad("rotate must be a multiple of 90"));
|
||||
}
|
||||
i.rotation = ((i64::from(i.rotation) * 90 + deg).rem_euclid(360) / 90) as u8;
|
||||
// Reduce untrusted degrees before adding the current quarter turns.
|
||||
i.rotation = (i64::from(i.rotation) + deg.rem_euclid(360) / 90).rem_euclid(4) as u8;
|
||||
}
|
||||
if a.opt_bool("flip_h")? == Some(true) {
|
||||
i.flip_h = !i.flip_h;
|
||||
@@ -187,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() })?;
|
||||
@@ -202,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 {
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`)
|
||||
//! than the root's is refused by name, before anything is resolved: resolving it would contact
|
||||
//! the host.
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
@@ -97,7 +100,10 @@ impl AutomationRoots {
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(shown, target)?, folder) {
|
||||
let absolute = absolute(shown, target)?;
|
||||
// Another network share or a device is refused by name, as `--root` refuses it: even
|
||||
// resolving the path would contact the host (#4003).
|
||||
if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = root_shown.display();
|
||||
@@ -123,9 +129,8 @@ fn plain(path: PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
/// `path` made absolute against the working directory, by name: nothing on disk is read.
|
||||
fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
@@ -133,10 +138,15 @@ fn resolve(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||
std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))
|
||||
}
|
||||
|
||||
/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing
|
||||
/// folder's links followed and the rest appended.
|
||||
fn resolve(shown: &str, absolute: &Path) -> Result<PathBuf, String> {
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
let mut at = absolute;
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
@@ -189,6 +199,24 @@ fn same_component(a: Component, b: Component) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names another network share or device namespace than `root` (Windows
|
||||
/// `\\host\share`, `\\?\UNC\…`, `\\.\…`, `\\?\…`). Those are refused by name: even checking
|
||||
/// that one exists would contact the host. Drive letters are left to the normal check.
|
||||
pub(crate) fn foreign_share(path: &Path, root: &Path) -> bool {
|
||||
use std::path::Prefix;
|
||||
fn share(p: &Path) -> Option<String> {
|
||||
let Some(Component::Prefix(prefix)) = p.components().next() else { return None };
|
||||
let (kind, a, b) = match prefix.kind() {
|
||||
Prefix::Disk(_) | Prefix::VerbatimDisk(_) => return None,
|
||||
Prefix::UNC(host, share) | Prefix::VerbatimUNC(host, share) => ("unc", host, share),
|
||||
Prefix::DeviceNS(name) => ("device", name, std::ffi::OsStr::new("")),
|
||||
Prefix::Verbatim(name) => ("verbatim", name, std::ffi::OsStr::new("")),
|
||||
};
|
||||
Some(format!("{kind}\\{}\\{}", a.to_string_lossy(), b.to_string_lossy()).to_lowercase())
|
||||
}
|
||||
share(path).is_some_and(|s| share(root).as_ref() != Some(&s))
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
@@ -233,6 +261,27 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// #4003: another share or a device namespace is refused by name, with the same words as any
|
||||
/// other path outside the root, before anything is resolved (which would contact the host).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn other_shares_and_devices_are_refused_before_they_are_resolved() {
|
||||
let (base, roots) = folders("shares");
|
||||
for p in [
|
||||
r"\\.\pipe\pdfcraft-4003.pdf",
|
||||
r"\\?\GLOBALROOT\Device\Null",
|
||||
r"\\pdfcraft-no-such-host.invalid\share\a.pdf",
|
||||
"//pdfcraft-no-such-host.invalid/share/a.pdf",
|
||||
r"\\?\UNC\pdfcraft-no-such-host.invalid\share\a.pdf",
|
||||
] {
|
||||
let e = roots.check(p, Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{p}: {e}");
|
||||
let e = roots.check(p, Access::Write).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the write root"), "{p}: {e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
@@ -345,7 +394,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 +415,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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
|
||||
//! prepare a form (add, change and delete fields).
|
||||
//! prepare a form (add fields one at a time or as a table, change and delete them).
|
||||
|
||||
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
|
||||
use pdfcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
|
||||
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, failed};
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -339,6 +356,67 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A table of text fields: `rect` (points from the top-left of the displayed page) divided
|
||||
/// into rows and columns, one field per cell, as one undo step.
|
||||
pub(crate) fn form_add_field_table(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::field_table::{self, FieldTable, MAX_COLUMNS, TableColumn, TableError};
|
||||
let page = self.page(a)?;
|
||||
let rect = a
|
||||
.get("rect")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|r| r.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
|
||||
.and_then(|r| <[f64; 4]>::try_from(r).ok())
|
||||
.ok_or_else(|| bad("rect must be 4 numbers"))?;
|
||||
let names = a.get("names").map(|_| a.strs("names")).transpose()?;
|
||||
let widths = match a.get("widths") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.and_then(|w| w.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
|
||||
.ok_or_else(|| bad("widths must be an array of numbers"))?,
|
||||
),
|
||||
};
|
||||
// A negative count is no count: the table refuses it with its own words.
|
||||
let counts = [a.opt_int("columns")?.map(|n| usize::try_from(n).unwrap_or(0)), names.as_ref().map(Vec::len), widths.as_ref().map(Vec::len)];
|
||||
let mut given = counts.into_iter().flatten();
|
||||
let columns = given.next().unwrap_or(1);
|
||||
if given.any(|n| n != columns) {
|
||||
return Err(bad("columns, names and widths must agree on the number of columns"));
|
||||
}
|
||||
// Before anything is sized by it.
|
||||
if !(1..=MAX_COLUMNS).contains(&columns) {
|
||||
return Err(bad(TableError::Columns.to_string()));
|
||||
}
|
||||
let (edit, fields) = {
|
||||
let doc = self.doc(a)?;
|
||||
let base = field_table::free_base(doc.form.iter().map(|f| f.name.as_str()));
|
||||
let mut table = FieldTable::new(rect, &base);
|
||||
table.rows = usize::try_from(a.int("rows")?).unwrap_or(0);
|
||||
table.columns = (0..columns)
|
||||
.map(|c| TableColumn {
|
||||
pattern: names.as_ref().and_then(|n| n.get(c)).map_or_else(|| field_table::column_pattern(&base, c + 1), |p| (*p).to_owned()),
|
||||
width: widths.as_ref().and_then(|w| w.get(c)).copied().unwrap_or(1.0),
|
||||
})
|
||||
.collect();
|
||||
table.row_gap = a.opt_num("row_gap")?.unwrap_or(0.0);
|
||||
table.column_gap = a.opt_num("column_gap")?.unwrap_or(0.0);
|
||||
table.first_row = a.opt_int("first_row")?.map_or(1, |n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
let info = doc.info.pages.get(page).ok_or_else(|| bad(format!("page {} does not exist", page + 1)))?;
|
||||
let edit = field_table::edit(&table, page, info, &doc.form).map_err(|e| bad(e.to_string()))?;
|
||||
// The names the batch adds, in the order it adds them.
|
||||
let fields: Vec<String> = match &edit {
|
||||
Edit::Batch { edits, .. } => {
|
||||
edits.iter().filter_map(|e| if let Edit::AddField { name, .. } = e { name.clone() } else { None }).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(edit, fields)
|
||||
};
|
||||
let mut out = self.apply(a, edit)?;
|
||||
out["fields"] = json!(fields);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
|
||||
if a.get("widget").is_some() && a.get("caption").is_none() {
|
||||
return Err(bad("widget requires caption"));
|
||||
@@ -386,12 +464,24 @@ impl Automation {
|
||||
} else {
|
||||
edits.pop().ok_or_else(|| bad("give a field name"))?
|
||||
};
|
||||
// A rename also renames the field in the formulas that read it.
|
||||
let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None };
|
||||
let mut out = self.apply(a, edit)?;
|
||||
if bulk {
|
||||
out["fields"] = json!(names);
|
||||
} else {
|
||||
out["field"] = json!(new_name);
|
||||
}
|
||||
if let Some(before) = before {
|
||||
let after = &self.doc(a)?.form;
|
||||
// The renamed field itself, found by object: the name stored can differ from the
|
||||
// argument (trimmed, kept under its parent's prefix).
|
||||
let own =
|
||||
names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name);
|
||||
let mut updated = pdfcraft_engine::form_calculations_changed(&before, after);
|
||||
updated.retain(|f| Some(f) != own);
|
||||
out["formulas_updated"] = json!(updated);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -405,7 +495,7 @@ impl Automation {
|
||||
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?;
|
||||
let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?;
|
||||
let p = &doc.info.pages[page];
|
||||
let p = doc.info.pages.get(page).ok_or_else(|| failed(format!("{name} is on page {}, which could not be read", page + 1)))?;
|
||||
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
|
||||
Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
|
||||
}
|
||||
@@ -524,6 +614,54 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn form_calculation_order(&mut self, a: &Args) -> Result<Value> {
|
||||
let current = self.doc(a)?.calculation_order();
|
||||
let fix = a.opt_bool("fix")?.unwrap_or(false);
|
||||
let field = a.opt_str("field")?;
|
||||
let order = a.get("order").map(|_| a.strs("order")).transpose()?;
|
||||
if usize::from(fix) + usize::from(field.is_some()) + usize::from(order.is_some()) > 1 {
|
||||
return Err(bad("give one change at a time: order, field with move, or fix"));
|
||||
}
|
||||
let names: Option<Vec<String>> = if let Some(order) = order {
|
||||
Some(order.into_iter().map(str::to_owned).collect())
|
||||
} else if let Some(field) = field {
|
||||
let earlier = match a.opt_str("move")?.unwrap_or("earlier") {
|
||||
"earlier" => true,
|
||||
"later" => false,
|
||||
m => return Err(bad(format!("move must be earlier or later, not {m:?}"))),
|
||||
};
|
||||
let mut names: Vec<String> = current.fields.iter().map(|f| f.name.clone()).collect();
|
||||
let i = names.iter().position(|n| n == field).ok_or_else(|| bad(format!("{field:?} is not a calculated field")))?;
|
||||
// Already first or last: nothing moves.
|
||||
let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) };
|
||||
j.map(|j| {
|
||||
names.swap(i, j);
|
||||
names
|
||||
})
|
||||
} else {
|
||||
(fix && current.fixable()).then(|| current.fixed())
|
||||
};
|
||||
let mut out = match names {
|
||||
Some(names) => self.apply(a, Edit::SetCalculationOrder { names })?,
|
||||
None => json!({}),
|
||||
};
|
||||
let order = self.doc(a)?.calculation_order();
|
||||
let issue = |i: &CalculationIssue| match i {
|
||||
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
|
||||
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
|
||||
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
|
||||
};
|
||||
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
|
||||
out["fields"] = order
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::<Vec<_>>() }))
|
||||
.collect();
|
||||
out["extra"] = json!(order.extra);
|
||||
out["fixable"] = json!(order.fixable());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn tab_sequence(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let mut seq: Vec<(usize, &str)> =
|
||||
@@ -563,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) })?;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Layers (optional content groups) as a tool: the Layers panel's eye icon (#2986, AGENTS.md §3).
|
||||
//!
|
||||
//! A per-document view setting, like hidden comments: `page_render` draws with it, and the file
|
||||
//! itself never changes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError};
|
||||
|
||||
impl Automation {
|
||||
/// Show or hide a layer, named by `layer`: its name as `doc_info` lists it, or its 1-based
|
||||
/// position in that list. A name picks every layer that has it, since CAD exporters often
|
||||
/// write one group per page or per drawing under the same name.
|
||||
pub(crate) fn layer_set(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
let visible = a.opt_bool("visible")?.ok_or_else(|| ToolError::InvalidArgs("missing argument visible".into()))?;
|
||||
let layers = &doc.info.layers;
|
||||
let targets: Vec<usize> = match a.get("layer") {
|
||||
Some(Value::String(name)) => layers.iter().enumerate().filter(|(_, l)| l.name == *name).map(|(i, _)| i).collect(),
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(n) if n >= 1 && usize::try_from(n - 1).is_ok_and(|i| i < layers.len()) => vec![(n - 1) as usize],
|
||||
Some(_) => Vec::new(),
|
||||
None => return Err(ToolError::InvalidArgs("layer must be a layer name or its 1-based position in doc_info's layers".into())),
|
||||
},
|
||||
None => return Err(ToolError::InvalidArgs("missing argument layer".into())),
|
||||
};
|
||||
if targets.is_empty() {
|
||||
let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
|
||||
return Err(ToolError::InvalidArgs(if names.is_empty() {
|
||||
"the document has no layers".into()
|
||||
} else {
|
||||
format!("no such layer; the document's layers are: {}", names.join(", "))
|
||||
}));
|
||||
}
|
||||
let mut changed = false;
|
||||
for i in targets {
|
||||
changed |= self.session.set_layer_visible(id, i, visible);
|
||||
}
|
||||
if changed {
|
||||
// The cached renderer drew with the old visibility; page_render must read it again.
|
||||
self.renderers.remove(&id);
|
||||
}
|
||||
let layers = &self.doc(a)?.info.layers;
|
||||
Ok(json!({ "layers": layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>() }))
|
||||
}
|
||||
}
|
||||
+342
-81
@@ -21,6 +21,7 @@ mod content;
|
||||
mod conventions;
|
||||
pub mod file_access;
|
||||
mod forms;
|
||||
mod layers;
|
||||
mod links;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod mcp;
|
||||
@@ -32,11 +33,11 @@ mod signing;
|
||||
mod text_cache;
|
||||
mod tools;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands};
|
||||
use pdfcraft_engine::{BlockKey, CombineSource, DocId, Document, Edit, Session, TextBlock, block_overlapping, commands};
|
||||
use pdfcraft_platform::staging::{StagingName, staging_suffixes};
|
||||
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
|
||||
use serde_json::{Value, json};
|
||||
@@ -96,12 +97,16 @@ const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
|
||||
/// Bookmarks in one page of the bookmark list, by default and at most.
|
||||
const BOOKMARK_PAGE: usize = 100;
|
||||
const MAX_BOOKMARK_PAGE: usize = 1000;
|
||||
/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at
|
||||
/// most 14 400 across).
|
||||
const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
|
||||
|
||||
/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a
|
||||
/// match near the start returns without touching the rest of a long document (#971).
|
||||
const FIND_FIRST_BATCH: usize = 8;
|
||||
const FIND_MAX_BATCH: usize = 128;
|
||||
|
||||
use file_access::foreign_share;
|
||||
use text_cache::TextCache;
|
||||
|
||||
type PageTexts = Vec<(usize, std::result::Result<Arc<PageText>, String>)>;
|
||||
@@ -118,6 +123,12 @@ pub struct Automation {
|
||||
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
|
||||
/// Extracted page text, under one memory budget across all open documents.
|
||||
texts: TextCache,
|
||||
/// The paragraphs of the last `text_paragraphs` listing of each page, so a paragraph number
|
||||
/// keeps naming that paragraph after edits regroup the page (#2516).
|
||||
listed: HashMap<(DocId, usize), Vec<BlockKey>>,
|
||||
/// Documents that lost a listing to the cap. A page of one with no listing is refused rather
|
||||
/// than read as "a number is a position", which the page may no longer match.
|
||||
evicted: HashSet<DocId>,
|
||||
}
|
||||
|
||||
impl Default for Automation {
|
||||
@@ -128,7 +139,15 @@ impl Default for Automation {
|
||||
|
||||
impl Automation {
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
Self {
|
||||
session: Session::new(),
|
||||
root: None,
|
||||
roots: None,
|
||||
renderers: HashMap::new(),
|
||||
texts: TextCache::default(),
|
||||
listed: HashMap::new(),
|
||||
evicted: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
|
||||
@@ -339,7 +358,13 @@ impl Automation {
|
||||
let parent = a.opt_path("parent")?.unwrap_or_default();
|
||||
let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize);
|
||||
let title = a.str("title")?.to_string();
|
||||
self.apply(&a, Edit::AddBookmark { parent, index, title, page })?
|
||||
let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?);
|
||||
let [left, top] = match self.doc(&a)?.info.pages.get(page) {
|
||||
Some(p) => p.xyz_for_view(left, top),
|
||||
None => [None, None],
|
||||
};
|
||||
let (left, top) = (left.map(f64::from), top.map(f64::from));
|
||||
self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })?
|
||||
}
|
||||
"bookmark_rename" => {
|
||||
let (path, title) = (a.path("path")?, a.str("title")?.to_string());
|
||||
@@ -359,6 +384,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)?;
|
||||
@@ -370,7 +425,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)?,
|
||||
@@ -411,7 +466,7 @@ impl Automation {
|
||||
"doc_revisions" => self.doc_revisions(&a)?,
|
||||
"text_lines" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc(&a)?;
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
|
||||
let lines: Vec<Value> = doc
|
||||
@@ -499,6 +554,11 @@ impl Automation {
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"rotate_by" => ImageEdit::RotateBy(
|
||||
a.get("degrees")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?,
|
||||
),
|
||||
"rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32),
|
||||
"crop" => {
|
||||
let r: Vec<f64> =
|
||||
@@ -514,7 +574,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),
|
||||
@@ -530,8 +590,9 @@ impl Automation {
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
|
||||
let blocks: Vec<Value> = doc
|
||||
.text_blocks(page)
|
||||
let current = doc.text_blocks(page);
|
||||
let id = doc.id;
|
||||
let blocks: Vec<Value> = current
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| {
|
||||
@@ -548,6 +609,7 @@ impl Automation {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.record_listing(id, page, ¤t);
|
||||
json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks })
|
||||
}
|
||||
"text_font_files" => {
|
||||
@@ -605,10 +667,8 @@ impl Automation {
|
||||
let doc = self.doc(&a)?;
|
||||
let k = a.int("paragraph")?;
|
||||
let blocks = doc.text_blocks(page);
|
||||
if k < 1 || k as usize > blocks.len() {
|
||||
return Err(ToolError::InvalidArgs("paragraph is out of range".into()));
|
||||
}
|
||||
let source = &blocks[k as usize - 1];
|
||||
let index = self.paragraph_index(doc.id, page, k, &blocks)?;
|
||||
let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?;
|
||||
let text = a.opt_str("text")?.unwrap_or(&source.editor_text);
|
||||
let style = pdfcraft_engine::BlockStyle {
|
||||
size: a.opt_num("size")?,
|
||||
@@ -617,7 +677,7 @@ impl Automation {
|
||||
ranges: parse_style_ranges(a.get("ranges"))?,
|
||||
..Default::default()
|
||||
};
|
||||
let layout = doc.text_edit_layout(page, k as usize - 1, text, &style).map_err(failed)?;
|
||||
let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let view_rect = |r: [f64; 4]| {
|
||||
let a = info.user_to_view(r[0] as f32, r[1] as f32);
|
||||
@@ -630,12 +690,13 @@ impl Automation {
|
||||
}
|
||||
"text_edit" if a.get("paragraph").is_some() => {
|
||||
let page = self.page(&a)?;
|
||||
let n = self.doc(&a)?.text_blocks(page).len();
|
||||
let k = a.int("paragraph")?;
|
||||
if k < 1 || k as usize > n {
|
||||
return Err(ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)));
|
||||
}
|
||||
let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone();
|
||||
let (id, blocks) = {
|
||||
let doc = self.doc(&a)?;
|
||||
(doc.id, doc.text_blocks(page))
|
||||
};
|
||||
let index = self.paragraph_index(id, page, k, &blocks)?;
|
||||
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
|
||||
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
|
||||
let mut style = pdfcraft_engine::BlockStyle {
|
||||
ranges: parse_style_ranges(a.get("ranges"))?,
|
||||
@@ -674,9 +735,31 @@ impl Automation {
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
|
||||
});
|
||||
}
|
||||
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: k as usize - 1, text, style })?;
|
||||
if let Some(b) = self.doc(&a)?.text_blocks(page).get(k as usize - 1) {
|
||||
out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font });
|
||||
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
|
||||
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
|
||||
let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy];
|
||||
let wanted: String = text.split_whitespace().collect();
|
||||
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?;
|
||||
let doc = self.doc(&a)?;
|
||||
let after = doc.text_blocks(page);
|
||||
// Without `text` the paragraph keeps the document's own words: not echoed when
|
||||
// the document doesn't allow copying them.
|
||||
let copy = doc.allows_copying();
|
||||
// Only a paragraph that now shows exactly the requested text is the edited one. A
|
||||
// neighbour that overlaps the old box (overlaid text, a text layer) must not take
|
||||
// its number, and neither must a paragraph the edit merged it into: rewriting that
|
||||
// one by this number would drop the neighbour's text.
|
||||
let edited = block_overlapping(&after, moved)
|
||||
.filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::<String>() == wanted));
|
||||
if let Some(b) = edited.and_then(|i| after.get(i)) {
|
||||
out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font });
|
||||
if copy {
|
||||
out["paragraph"]["text"] = json!(b.text);
|
||||
}
|
||||
}
|
||||
let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after);
|
||||
if !stale.is_empty() {
|
||||
out["stale_paragraphs"] = json!(stale);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -721,14 +804,16 @@ 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)?,
|
||||
"form_add_field" => self.form_add_field(&a)?,
|
||||
"form_add_field_table" => self.form_add_field_table(&a)?,
|
||||
"form_set_props" => self.form_set_props(&a)?,
|
||||
"form_delete_field" => self.form_delete_field(&a)?,
|
||||
"form_tab_order" => self.form_tab_order(&a)?,
|
||||
"form_calculation_order" => self.form_calculation_order(&a)?,
|
||||
"doc_export_data" => self.doc_export_data(&a)?,
|
||||
"doc_import_data" => self.doc_import_data(&a)?,
|
||||
"redact_mark" => self.redact_mark(&a)?,
|
||||
@@ -751,7 +836,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)?,
|
||||
@@ -790,6 +895,14 @@ impl Automation {
|
||||
"print_readiness" => self.print_readiness(&a)?,
|
||||
"doc_export_office" => self.doc_export_office(&a)?,
|
||||
"pdfa_convert" => self.pdfa_convert(&a)?,
|
||||
"standards_remove_id" => {
|
||||
let standard = match a.str("standard")? {
|
||||
"pdfa" => pdfcraft_engine::Standard::PdfA,
|
||||
"pdfx" => pdfcraft_engine::Standard::PdfX,
|
||||
other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))),
|
||||
};
|
||||
self.apply(&a, Edit::RemoveStandardId { standard })?
|
||||
}
|
||||
"action_run" => self.action_run(&a)?,
|
||||
"doc_compare_report" => self.doc_compare_report(&a)?,
|
||||
"doc_compare_mark" => self.doc_compare_mark(&a)?,
|
||||
@@ -846,6 +959,7 @@ impl Automation {
|
||||
"comment_mark" => self.comment_mark(&a)?,
|
||||
"comment_lock" => self.comment_lock(&a)?,
|
||||
"comments_hide" => self.comments_hide(&a)?,
|
||||
"layer_set" => self.layer_set(&a)?,
|
||||
"view_reading_colors" => self.view_reading_colors(&a)?,
|
||||
"comments_summarize" => self.comments_summarize(&a)?,
|
||||
"comment_edit" => self.comment_edit(&a)?,
|
||||
@@ -882,6 +996,59 @@ impl Automation {
|
||||
self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)")))
|
||||
}
|
||||
|
||||
/// Most pages whose paragraph listing is remembered.
|
||||
const MAX_LISTINGS: usize = 256;
|
||||
|
||||
/// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be
|
||||
/// resolved against that listing.
|
||||
fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) {
|
||||
if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) {
|
||||
self.evicted.extend(self.listed.keys().map(|(d, _)| *d));
|
||||
self.listed.clear();
|
||||
}
|
||||
self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect());
|
||||
}
|
||||
|
||||
/// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based).
|
||||
///
|
||||
/// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is
|
||||
/// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a
|
||||
/// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph
|
||||
/// and lose its text (#2516). So is any number on a page of a document whose listings were
|
||||
/// dropped at the cap, until the page is listed again.
|
||||
fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result<usize> {
|
||||
let keys = self.listed.get(&(doc, page));
|
||||
if keys.is_none() && self.evicted.contains(&doc) {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs",
|
||||
page + 1
|
||||
)));
|
||||
}
|
||||
let n = keys.map_or(blocks.len(), Vec::len);
|
||||
let i = usize::try_from(k)
|
||||
.ok()
|
||||
.and_then(|k| k.checked_sub(1))
|
||||
.filter(|i| *i < n)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?;
|
||||
let Some(keys) = keys else { return Ok(i) };
|
||||
keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| {
|
||||
ToolError::InvalidArgs(format!(
|
||||
"paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs",
|
||||
page + 1
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Update the listing after paragraph `k` was edited into `edited`, one of the page's
|
||||
/// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone.
|
||||
fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec<usize> {
|
||||
let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() };
|
||||
if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) {
|
||||
*key = BlockKey::of(edited);
|
||||
}
|
||||
keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect()
|
||||
}
|
||||
|
||||
/// The document, if its security allows copying text and images out of it (Table 22, bit 5).
|
||||
fn doc_to_copy(&self, a: &Args) -> Result<&Document> {
|
||||
let doc = self.doc(a)?;
|
||||
@@ -891,7 +1058,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"))?;
|
||||
@@ -908,6 +1075,8 @@ impl Automation {
|
||||
self.session.close(id);
|
||||
self.renderers.remove(&id);
|
||||
self.texts.remove(&id);
|
||||
self.listed.retain(|(d, _), _| *d != id);
|
||||
self.evicted.remove(&id);
|
||||
Ok(json!({ "closed": id.0 }))
|
||||
}
|
||||
|
||||
@@ -1067,7 +1236,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)?;
|
||||
@@ -1075,6 +1244,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;
|
||||
@@ -1108,6 +1281,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),
|
||||
] {
|
||||
@@ -1130,9 +1305,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,
|
||||
@@ -1146,6 +1338,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,
|
||||
@@ -1159,7 +1352,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(),
|
||||
@@ -1199,6 +1392,14 @@ impl Automation {
|
||||
if let Some(s) = a.opt_num("font_size")? {
|
||||
hf.font_size = s;
|
||||
}
|
||||
hf.font = match a.opt_str("font")? {
|
||||
None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica,
|
||||
Some("Times") => pdfcraft_engine::FontFamily::Times,
|
||||
Some("Courier") => pdfcraft_engine::FontFamily::Courier,
|
||||
Some(other) => {
|
||||
return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}")));
|
||||
}
|
||||
};
|
||||
hf.color = color("color", hf.color)?;
|
||||
if let Some(m) = a.get("margins").and_then(Value::as_array) {
|
||||
let m: Vec<f64> = m.iter().filter_map(Value::as_f64).collect();
|
||||
@@ -1207,6 +1408,7 @@ impl Automation {
|
||||
if let Some(s) = a.opt_int("start_number")? {
|
||||
hf.start_number = s.clamp(1, u32::MAX as i64) as u32;
|
||||
}
|
||||
hf.shrink = a.opt_bool("shrink")?.unwrap_or(false);
|
||||
Edit::AddHeaderFooter { pages, settings: hf, replace }
|
||||
}
|
||||
"doc_watermark" => {
|
||||
@@ -1324,7 +1526,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 {
|
||||
@@ -1339,7 +1541,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())),
|
||||
@@ -1514,7 +1716,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 })
|
||||
@@ -1557,7 +1759,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();
|
||||
@@ -1574,7 +1776,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())?;
|
||||
@@ -1678,7 +1880,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)?
|
||||
@@ -1809,11 +2011,11 @@ impl Automation {
|
||||
|
||||
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
|
||||
let colors = self.session.reading_colors(id).unwrap_or_default();
|
||||
let fresh = || {
|
||||
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() };
|
||||
(doc.display.clone(), PageRenderer::new(doc.display.clone(), config))
|
||||
};
|
||||
// The document's own view settings: layer visibility, hidden comments, reading colours
|
||||
// (tools that change one drop this cache so the next render reads it again).
|
||||
let config =
|
||||
self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() });
|
||||
let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone()));
|
||||
let entry = self.renderers.entry(id).or_insert_with(fresh);
|
||||
if !Arc::ptr_eq(&entry.0, &doc.display) {
|
||||
*entry = fresh();
|
||||
@@ -1835,11 +2037,13 @@ impl Automation {
|
||||
return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into()));
|
||||
}
|
||||
let out = if let Some(block) = block {
|
||||
let index = usize::try_from(block)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?;
|
||||
self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
if block < 1 {
|
||||
return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()));
|
||||
}
|
||||
// The number names the paragraph text_paragraphs listed, as it does for text_edit.
|
||||
let doc = self.doc(a)?;
|
||||
let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?;
|
||||
doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
} else if let Some(added) = added {
|
||||
let index = usize::try_from(added)
|
||||
.ok()
|
||||
@@ -2169,24 +2373,6 @@ fn lexical(path: &Path) -> Option<PathBuf> {
|
||||
Some(parts.iter().collect())
|
||||
}
|
||||
|
||||
/// Whether `path` names another network share or device namespace than `root` (Windows
|
||||
/// `\\host\share`, `\\?\UNC\…`, `\\.\…`, `\\?\…`). Those are refused by name: even checking
|
||||
/// that one exists would contact the host. Drive letters are left to the normal check.
|
||||
fn foreign_share(path: &Path, root: &Path) -> bool {
|
||||
use std::path::{Component, Prefix};
|
||||
fn share(p: &Path) -> Option<String> {
|
||||
let Some(Component::Prefix(prefix)) = p.components().next() else { return None };
|
||||
let (kind, a, b) = match prefix.kind() {
|
||||
Prefix::Disk(_) | Prefix::VerbatimDisk(_) => return None,
|
||||
Prefix::UNC(host, share) | Prefix::VerbatimUNC(host, share) => ("unc", host, share),
|
||||
Prefix::DeviceNS(name) => ("device", name, std::ffi::OsStr::new("")),
|
||||
Prefix::Verbatim(name) => ("verbatim", name, std::ffi::OsStr::new("")),
|
||||
};
|
||||
Some(format!("{kind}\\{}\\{}", a.to_string_lossy(), b.to_string_lossy()).to_lowercase())
|
||||
}
|
||||
share(path).is_some_and(|s| share(root).as_ref() != Some(&s))
|
||||
}
|
||||
|
||||
// ---- JSON helpers ----------------------------------------------------------------------------
|
||||
|
||||
fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
|
||||
@@ -2287,6 +2473,15 @@ impl Args<'_> {
|
||||
let Some(v) = self.opt_ints(key)? else { return Ok(None) };
|
||||
v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
/// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error
|
||||
/// when it is not a number or lies absurdly far off the page.
|
||||
fn bookmark_coordinate(&self, key: &str) -> Result<Option<f32>> {
|
||||
match self.opt_num(key)? {
|
||||
None => Ok(None),
|
||||
Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)),
|
||||
Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))),
|
||||
}
|
||||
}
|
||||
fn strs(&self, key: &str) -> Result<Vec<&str>> {
|
||||
let v = self.get(key).ok_or_else(|| Self::missing(key))?;
|
||||
let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?;
|
||||
@@ -2294,6 +2489,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> {
|
||||
@@ -2307,6 +2514,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;
|
||||
}
|
||||
@@ -2418,6 +2635,9 @@ fn info(d: &Document) -> Value {
|
||||
"creator": i.creator, "producer": i.producer,
|
||||
"tagged": i.tagged,
|
||||
"has_javascript": i.has_javascript,
|
||||
// Where the document opens (/OpenAction, named destinations resolved): 1-based page and
|
||||
// destination, null without one.
|
||||
"open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })),
|
||||
// XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic"
|
||||
// (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed).
|
||||
"xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||||
@@ -2438,7 +2658,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),
|
||||
@@ -2460,7 +2681,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" }),
|
||||
@@ -2472,7 +2694,7 @@ fn info(d: &Document) -> Value {
|
||||
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
|
||||
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
|
||||
// a `/Trans` or `/Dur` appear; `style` is null for a display time alone.
|
||||
"transitions": i.transitions.iter().map(|t| json!({
|
||||
@@ -2506,24 +2728,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
|
||||
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
|
||||
}
|
||||
|
||||
/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same).
|
||||
fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut rgba = premultiplied.to_vec();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let a = u32::from(px[3]);
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, width, height);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut w = enc.write_header().map_err(failed)?;
|
||||
w.write_image_data(&rgba).map_err(failed)?;
|
||||
w.finish().map_err(failed)?;
|
||||
Ok(out)
|
||||
pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
|
||||
}
|
||||
|
||||
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
|
||||
@@ -2536,7 +2743,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);
|
||||
@@ -2584,6 +2791,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.
|
||||
@@ -3141,4 +3370,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);
|
||||
}
|
||||
}
|
||||
@@ -70,9 +70,8 @@ impl Automation {
|
||||
.links()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let p = &doc.info.pages[l.page];
|
||||
let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32));
|
||||
let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0);
|
||||
// The link's page may be one the viewer could not read (#2660): rect unchanged then.
|
||||
let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32));
|
||||
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
|
||||
// Where an in-document link goes, as the viewer resolves it: named destinations
|
||||
// included, with the position on the page.
|
||||
@@ -118,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.");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use crate::{Args, Automation, Result, ToolError, failed};
|
||||
use pdfcraft_engine::{
|
||||
Edit, Style,
|
||||
measure::{self, Kind, NewMeasurement, Scale},
|
||||
measure::{self, Kind, NewMeasurement, PageUnit, Scale},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -78,16 +78,30 @@ impl Automation {
|
||||
let info = self.doc(a)?.info.pages.get(page).ok_or_else(|| failed("no such page"))?.clone();
|
||||
let precision = u8::try_from(a.opt_int("precision")?.unwrap_or(2)).map_err(|_| failed("precision must be between 0 and 6"))?;
|
||||
let unit = a.opt_str("unit")?.unwrap_or("in");
|
||||
if ["points", "units_per_point", "page_distance"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
|
||||
return Err(failed("pass one of calibration points, units_per_point or page_distance"));
|
||||
}
|
||||
if a.get("page_unit").is_some() && a.get("page_distance").is_none() {
|
||||
return Err(failed("page_unit is the unit of page_distance; pass page_distance too"));
|
||||
}
|
||||
let scale = if a.get("points").is_some() {
|
||||
if a.get("units_per_point").is_some() {
|
||||
return Err(failed("pass calibration points or units_per_point, not both"));
|
||||
}
|
||||
let (_, points) = self.measurement_points(a)?;
|
||||
let [from, to] = points.as_slice() else {
|
||||
return Err(failed("scale calibration needs exactly two points"));
|
||||
};
|
||||
let length = a.opt_num("distance")?.ok_or_else(|| failed("calibration needs a real-world distance"))?;
|
||||
Scale::calibrate(*from, *to, length, unit, precision).map_err(failed)?
|
||||
} else if let Some(page_distance) = a.opt_num("page_distance")? {
|
||||
// A ratio, "page_distance page_unit on the page = distance unit"; the page unit
|
||||
// defaults to the real-world one, as "1:50" means 1 mm = 50 mm.
|
||||
let page_unit = match a.opt_str("page_unit")? {
|
||||
Some(u) => PageUnit::from_label(u).ok_or_else(|| failed("page_unit must be pt, in, mm or cm"))?,
|
||||
None => PageUnit::from_label(unit).ok_or_else(|| failed("pass page_unit (pt, in, mm or cm) when unit is not one of them"))?,
|
||||
};
|
||||
let length = a.opt_num("distance")?.ok_or_else(|| failed("a scale ratio needs the real-world distance"))?;
|
||||
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
|
||||
let to = self.doc(a)?.measurement_to_user(page, [1.0, 0.0]).map_err(failed)?;
|
||||
Scale::ratio(from, to, page_distance, page_unit, length, unit, precision).map_err(failed)?
|
||||
} else if let Some(factor) = a.opt_num("units_per_point")? {
|
||||
{
|
||||
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
|
||||
|
||||
@@ -6,11 +6,88 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
|
||||
|
||||
/// The job's two-sided setting for `doc_print`'s `duplex`: `printer` sends none, so the printer's
|
||||
/// own default applies (as the Print dialog's Printer default, #2422), except that cut and stack
|
||||
/// needs single-sided sheets.
|
||||
fn duplex(value: &str, cut_stack: bool) -> Result<Option<spool::Duplex>> {
|
||||
Ok(match value {
|
||||
"printer" if cut_stack => Some(spool::Duplex::Off),
|
||||
"printer" => None,
|
||||
"off" => Some(spool::Duplex::Off),
|
||||
"long-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")),
|
||||
"short-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")),
|
||||
"long-edge" => Some(spool::Duplex::LongEdge),
|
||||
"short-edge" => Some(spool::Duplex::ShortEdge),
|
||||
d => return Err(bad(format!("unknown duplex {d:?}"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// [`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()
|
||||
@@ -37,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());
|
||||
@@ -92,9 +171,7 @@ impl Automation {
|
||||
},
|
||||
l => return Err(bad(format!("unknown layout {l:?}"))),
|
||||
};
|
||||
if matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }) && a.opt_str("duplex")?.unwrap_or("off") != "off" {
|
||||
return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)"));
|
||||
}
|
||||
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"));
|
||||
}
|
||||
@@ -111,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) {
|
||||
@@ -148,32 +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: Some(match a.opt_str("duplex")?.unwrap_or("off") {
|
||||
"off" => spool::Duplex::Off,
|
||||
"long-edge" => spool::Duplex::LongEdge,
|
||||
"short-edge" => spool::Duplex::ShortEdge,
|
||||
d => return Err(bad(format!("unknown duplex {d:?}"))),
|
||||
}),
|
||||
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\")")),
|
||||
@@ -181,3 +220,60 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn duplex_printer_leaves_two_sided_to_the_printer() {
|
||||
assert_eq!(duplex("printer", false).ok(), Some(None));
|
||||
assert_eq!(duplex("printer", true).ok(), Some(Some(spool::Duplex::Off)));
|
||||
assert_eq!(duplex("off", false).ok(), Some(Some(spool::Duplex::Off)));
|
||||
assert_eq!(duplex("long-edge", false).ok(), Some(Some(spool::Duplex::LongEdge)));
|
||||
assert_eq!(duplex("short-edge", false).ok(), Some(Some(spool::Duplex::ShortEdge)));
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,19 @@ impl Automation {
|
||||
pub(crate) fn redact_mark(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, n) = (doc.id, doc.info.pages.len());
|
||||
let pages: Vec<usize> = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
if a.get("page").is_some() && a.get("pages").is_some() {
|
||||
return Err(ToolError::InvalidArgs("pass page or pages, not both".into()));
|
||||
}
|
||||
if a.get("rect").is_some() && a.get("pages").is_some() {
|
||||
return Err(ToolError::InvalidArgs("rect requires page, not pages".into()));
|
||||
}
|
||||
let pages: Vec<usize> = if a.get("page").is_some() {
|
||||
vec![self.page(a)?]
|
||||
} else {
|
||||
match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
}
|
||||
};
|
||||
let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) {
|
||||
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())),
|
||||
|
||||
@@ -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),
|
||||
@@ -208,7 +208,7 @@ impl Automation {
|
||||
};
|
||||
let rect = match a.nums::<4>("rect")? {
|
||||
Some(r) => {
|
||||
let p = &doc.info.pages[page];
|
||||
let p = doc.info.pages.get(page).ok_or_else(|| bad("the document has no page to put the signature on"))?;
|
||||
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
|
||||
let u = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64];
|
||||
if u[2] - u[0] < 4.0 || u[3] - u[1] < 4.0 {
|
||||
@@ -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)?;
|
||||
|
||||
+127
-35
@@ -129,11 +129,17 @@ 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"])),
|
||||
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.")
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.")
|
||||
.ro()
|
||||
.cmd("file.properties")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -161,7 +167,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
|
||||
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." },
|
||||
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
|
||||
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
|
||||
}), &["doc", "page"])),
|
||||
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
|
||||
@@ -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(
|
||||
@@ -289,8 +295,21 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema(
|
||||
json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }),
|
||||
t(
|
||||
"bookmark_add",
|
||||
"Add a bookmark",
|
||||
"Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. It goes to the top of the page, or to a place on it: `top` and `left` are points from the top-left of the displayed page (like the destination bookmark_list reports), each optional, and a coordinate left out keeps the reader's position that way. The zoom is kept. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"parent": path("Parent bookmark (omit for the top level)"),
|
||||
"position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
|
||||
"top": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the top of the displayed page." },
|
||||
"left": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the left of the displayed page." }
|
||||
}),
|
||||
&["doc", "title", "page"],
|
||||
)),
|
||||
t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.")
|
||||
@@ -304,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",
|
||||
@@ -336,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!({
|
||||
@@ -355,7 +389,28 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "page", "type", "rect"],
|
||||
)),
|
||||
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(
|
||||
"form_add_field_table",
|
||||
"Prepare form: add a table of text fields",
|
||||
"Divide rect into rows and columns of text fields, one per cell, as one undo step. rect in points from the top-left of the displayed page [x0, y0, x1, y1]; rows 1-100 from the top; columns 1-50, or as many as names or widths list. Each column names its cells from its pattern in names: an N on its own after _ - / or : (price_N), # or {row} is the row number, {col} the column number, {seq} counts every cell along the rows, starting at first_row; with none of them the row number goes at the end. Rows count from first_row (default 1). Without names the columns are Table1_N, Table1_c2_N, … using the first free TableK. widths are relative (default equal); row_gap and column_gap are points between cells (default 0). Nothing is added if a cell would be under 4 points or a name would contain a period, repeat, or already exist; the error says which. Returns the fields' names. Undoable.",
|
||||
)
|
||||
.cmd("form.add.text_table")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"rows": { "type": "integer", "minimum": 1, "maximum": 100 },
|
||||
"columns": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||||
"names": { "type": "array", "items": { "type": "string" }, "description": "One name pattern per column." },
|
||||
"widths": { "type": "array", "items": { "type": "number", "exclusiveMinimum": 0 }, "description": "Relative column widths, one per column." },
|
||||
"row_gap": { "type": "number", "minimum": 0 },
|
||||
"column_gap": { "type": "number", "minimum": 0 },
|
||||
"first_row": { "type": "integer", "minimum": 0, "maximum": 99999 },
|
||||
}),
|
||||
&["doc", "page", "rect", "rows"],
|
||||
)),
|
||||
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.")
|
||||
.cmd("comment.stamp")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -363,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. 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!({
|
||||
@@ -410,6 +466,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
|
||||
.cmd("form.calculation_order")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
|
||||
@@ -421,12 +483,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"redact_mark",
|
||||
"Mark for redaction",
|
||||
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
|
||||
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"page": { "type": "integer", "minimum": 1, "description": "Single page to mark; mutually exclusive with pages." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"find": { "type": "string", "minLength": 1 },
|
||||
"words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 },
|
||||
@@ -434,7 +496,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
|
||||
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
|
||||
"whole_pages": { "type": "boolean" },
|
||||
"pages": pages("to search or mark (default: all)"),
|
||||
"pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"),
|
||||
"overlay": { "type": "string" },
|
||||
"code_set": { "type": "string", "enum": ["foia", "privacy-act"] },
|
||||
"codes": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Redaction codes from code_set, e.g. [\"(b)(6)\"]." },
|
||||
@@ -487,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, or A0/A1/A2/A3/A4/A5/A6 (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!({
|
||||
@@ -512,7 +574,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"printer": { "type": "string" },
|
||||
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
|
||||
"collate": { "type": "boolean" },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." },
|
||||
"grayscale": { "type": "boolean" },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." },
|
||||
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
|
||||
@@ -526,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(
|
||||
@@ -538,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"],
|
||||
)),
|
||||
@@ -551,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 },
|
||||
@@ -559,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"])),
|
||||
@@ -616,8 +694,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro().cmd("measure.info").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"points":measure_points(),"type":{"type":"string","enum":["distance","perimeter","area"]}}), &["doc","page","points"])),
|
||||
t("measure_list", "List measurements", "Saved measurement annotations with calculated values, scale and vertices in display coordinates. Measurements with unsupported imported formats (compound or fractional units, non-rectilinear scales) or invalid geometry are listed under unsupported with a reason.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc"])),
|
||||
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
|
||||
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
|
||||
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using a ratio (page_distance in page_unit on the page = distance in unit, e.g. 1 mm = 50 mm for a 1:50 plan; page_unit defaults to unit), units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
|
||||
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"page_distance":{"type":"number","exclusiveMinimum":0},"page_unit":{"type":"string","enum":["pt","in","mm","cm"]},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
|
||||
t("measure_snap", "Snap a measurement vertex", "Snap a point to vector paths, endpoints, midpoints or intersections. Coordinates and tolerance are in display points. Bounded extraction reports truncated geometry.")
|
||||
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
|
||||
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
|
||||
@@ -653,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 },
|
||||
@@ -750,7 +828,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
)),
|
||||
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
|
||||
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, a .p7b/.p7c bundle, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
|
||||
json!({ "paths": { "type": "array", "items": { "type": "string" } }, "password": { "type": "string" }, "clear": { "type": "boolean" } }),
|
||||
&[],
|
||||
)),
|
||||
@@ -762,6 +840,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })),
|
||||
&["doc"],
|
||||
)),
|
||||
t("layer_set", "Show or hide a layer", "Show or hide a layer (optional content group), as the Layers panel's eye icon does. layer is its name as doc_info lists it (every layer with that name) or its 1-based position in that list. A view setting: page_render draws with it, and the file doesn't change. Returns every layer with its visibility.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "layer": { "type": ["string", "integer"], "description": "A layer name, or a 1-based position in doc_info's layers." }, "visible": { "type": "boolean" } }),
|
||||
&["doc", "layer", "visible"],
|
||||
)),
|
||||
t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.")
|
||||
.cmd("comment.hide_all")
|
||||
.with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])),
|
||||
@@ -784,16 +867,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"width": { "type": "number", "minimum": 0 },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"move": point(),
|
||||
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
|
||||
})),
|
||||
&["doc"],
|
||||
)),
|
||||
@@ -869,7 +953,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_header_footer",
|
||||
"Add header and footer",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. replace: true swaps out existing ones (Update). Undoable.",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
|
||||
)
|
||||
.cmd("edit.header_footer")
|
||||
.with(schema(
|
||||
@@ -878,10 +962,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"pages": pages("to mark (default: all)"),
|
||||
"header_left": { "type": "string" }, "header_center": { "type": "string" }, "header_right": { "type": "string" },
|
||||
"footer_left": { "type": "string" }, "footer_center": { "type": "string" }, "footer_right": { "type": "string" },
|
||||
"font": { "type": "string", "enum": ["Helvetica", "Times", "Courier"] },
|
||||
"font_size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"color": { "type": "string", "description": "#RRGGBB or a colour name." },
|
||||
"margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." },
|
||||
"start_number": { "type": "integer", "minimum": 1 },
|
||||
"shrink": { "type": "boolean", "description": "Shrink the page content so the header and footer don't cover it (default false)." },
|
||||
"replace": { "type": "boolean" },
|
||||
}),
|
||||
&["doc"],
|
||||
@@ -1005,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(
|
||||
@@ -1023,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.")
|
||||
@@ -1046,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." } }),
|
||||
@@ -1065,7 +1151,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
@@ -1078,6 +1164,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
|
||||
.cmd("standards.pdfa")
|
||||
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
|
||||
t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])),
|
||||
t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).")
|
||||
.ro()
|
||||
.cmd("actions.wizard")
|
||||
@@ -1122,7 +1210,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
)),
|
||||
t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.")
|
||||
.with(schema(json!({ "enabled": { "type": "boolean" } }), &[])),
|
||||
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
|
||||
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
|
||||
.cmd("ocr.recognize")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1134,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!({
|
||||
@@ -1246,16 +1334,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"image": { "type": "integer", "minimum": 1 },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"degrees": { "type": "number" },
|
||||
"path": { "type": "string" },
|
||||
}),
|
||||
&["doc", "page", "image", "action"],
|
||||
@@ -1273,7 +1362,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
|
||||
.ro().cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
|
||||
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.")
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
@@ -1281,7 +1370,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])),
|
||||
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
|
||||
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
|
||||
@@ -1308,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" },
|
||||
@@ -1323,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}");
|
||||
}
|
||||
+2319
-28
File diff suppressed because it is too large.
Load diff
Loaded 100 of 598 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user