mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
M3: integrate page-edge shadow preference with current main (#1577)
This commit is contained in:
commit
db723fd087
406 files changed
+62888
-3875
No files matched your search
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/crates/filters/fuzz"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -48,13 +48,21 @@ jobs:
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
- 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
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
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
|
||||
+4
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
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).
|
||||
|
||||
## In this repository (270)
|
||||
## In this repository (272)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -27,7 +27,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `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; reviewed by a native Lithuanian-speaking contributor | Lithuanian 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 |
|
||||
| `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 |
|
||||
@@ -277,7 +277,9 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/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 |
|
||||
| `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 |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
|
||||
+46
-22
@@ -7,7 +7,7 @@ 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 = "a4b95023bc2ab99b8712acecb529959b261704400398551f3f73390de887f621"
|
||||
sha256 = "26e9a240b7125681c2379e29cfd71937c0b1c6e0c5a9a026dacf3906845d8cb4"
|
||||
|
||||
# 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`).
|
||||
@@ -30,7 +30,7 @@ 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 = "6427553770a182872b31963640ae25c17bef7ca7749aa6f94ba0f2fd32d9cf50"
|
||||
sha256 = "3649b775b7808ca6f2a9da1b56a6dfc3634b57541efa0dc325ebf041a994482c"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -41,7 +41,7 @@ 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 = "2c55dc2c15a19f1c718037afe2ca0ac413978776fe065a1d8c48ff8f76e28338"
|
||||
sha256 = "0506e401d7357af9873111e03bf911f13f40be7857be6ab31dce687b4ecb8735"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -52,7 +52,7 @@ 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 = "ce58e69fbcfd0f399d6cdd7d9fccf5ff4509e4ee47c58e6426daee7c59fd4f89"
|
||||
sha256 = "7b74e3624571e4b3642baed5b2daef23b6a1b506fb0598df35627fd4ab4735fe"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -63,7 +63,7 @@ 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 = "1037bf896b4d7bfe2c1ecd2e4b488bb4c78655cc867d16cb65e714adf907b0c2"
|
||||
sha256 = "0df329eb84e7c58f7ea363f5bc782a04ffdddaaece73395f2f8e278214847d3e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -74,7 +74,7 @@ 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 = "6ab674b38045f215dcb643aedafe6763f336a670f321b8011df5f6bb0b9238cc"
|
||||
sha256 = "dc7857bc1b140f0aec7bf1959ad035797d6dd1faaba6b41b639fb9078f703bd0"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -85,7 +85,7 @@ 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 = "bbba95ed5c1bcbae086a3cbe30b05d8d0daae5a90e279d0636b33e8f7f1b85fe"
|
||||
sha256 = "41c942beee26b0c7c7e8d93552f7d8396ca6b4d105c1025c0afbed7ddf7d9d51"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -96,7 +96,7 @@ 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 = "915785e53cf2844a19d2abd7120f2f1792e2555b1224f441ec3cf6985a841b40"
|
||||
sha256 = "9ddca6461147cb35293752b1b40fdbfbd94fe074b90071e889a870f760cc9d2c"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,7 @@ 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 = "1632acf0d68b8127203b0435d67659fdd8975df6a4100715e64d301892746d2f"
|
||||
sha256 = "2426510d3535176a4525ebce4a1a819eb13cfe10ba5acdf47c5856f1d6b0626a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -118,7 +118,7 @@ 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 = "2ed055bcec912a14caba6e58b2855a07c19ab7e58500151cbe5f33ef56e80c18"
|
||||
sha256 = "691e3b6babf52534961c35c6a1a2811f0ed7897c2e7cf2374bb9b9ad0055dc35"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -129,7 +129,7 @@ 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 = "22a6ef932a0560b27df30bd09b8cc9ec1e463affd1c76ece9732b836155d8536"
|
||||
sha256 = "7ff4c8a636dc2dd0d57b26d0532ea1267e0f9e3cfb3772f407e935afb21f8250"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -140,7 +140,7 @@ 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 = "016d47686f8d7bad07637433465a8a94e4a55f3dd86301adaab02db98b221abe"
|
||||
sha256 = "532a739bc039db930952dc9548dc97bb4773e1b2657d693b668b21a9cafa9e6d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -151,7 +151,7 @@ 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 = "b709824ac987be697e3db542524c0bfb64546460c470898689163814ca356541"
|
||||
sha256 = "49e8a211b044c23a19a6267b2ee839b29b9809277f6f1d0cae410b3b49df9e97"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -162,7 +162,7 @@ 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 = "cd50450b84a8bada1c75c3d04e4f1f5f7ea5dba9c8b5f4955d6757677bdd5799"
|
||||
sha256 = "1463d7f57aa28e9d2632af09e0db72db297390a281dc6f50b7185678309ab7ec"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
@@ -173,7 +173,7 @@ 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 = "9277a45401d06cac680506ab5b7100093df3b7bca4bc3f1130145d387947446d"
|
||||
sha256 = "bc40d7ec61f6a33f758bb0300e64f039deab71a7f0a4ad048ae8225b353d7357"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -184,7 +184,7 @@ 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 = "d9e20c4e60c4460105f6395e2ab6bb5f49c574399ded9130499e03f29559088f"
|
||||
sha256 = "bd322f64034763c27d9132757bfba9cf7a26ee276841eef1697c3cb150bafb82"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -195,7 +195,7 @@ 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 = "cafd5475da2d8c2613a84f7790fb5b9c421945163621468dff2b3f0b0d86c12e"
|
||||
sha256 = "a2f31769fbfae70f690b96613ecd73b9a44bf966cbc01d97d7f8c8934e051c64"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -206,7 +206,7 @@ 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 = "fa4521f7ecc50ffb8e5186ad5de80f9ba4a67799f580e9cb8a84c824efd7d699"
|
||||
sha256 = "579f67e82d430a959b7edd0f1598b2bba615f04131c3551b214f1a44cf2d22e0"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,18 +217,19 @@ 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 = "a84024e205bb9b71b801de9f9d5f0b7967007c4d4da44bd7bab2ea415daa3de8"
|
||||
sha256 = "1887dac4914482e78374c310f70f51b3f5b2e0eb98d5715fb3d7764567de1666"
|
||||
|
||||
[[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; reviewed by a native Lithuanian-speaking contributor"
|
||||
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"
|
||||
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 = "fc41d9d7007ec2111bde170b0581036c083b410d582fe753e156abe272d963f7"
|
||||
sha256 = "6ee132715f8cebca0fafdee1e5d2ece4316483c373b0d5ee6179790391ffd327"
|
||||
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -3342,6 +3343,17 @@ kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
|
||||
|
||||
[[asset]]
|
||||
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"
|
||||
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 = "cb5645e571588db59ce5ec5f5bc697434d772ca6bba72dce8bad8252f064f50e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
title = "Slovene interface translation catalog"
|
||||
@@ -3351,4 +3363,16 @@ 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 = "4f7f2a3b506bb5590b32ca51165d99fa9fc78f4606110f59f89d8e7667bece02"
|
||||
sha256 = "97ce10bae54e21d74a4d9eb3bfbc1a85feff616e0d336e124d79baeafce01b02"
|
||||
|
||||
[[asset]]
|
||||
|
||||
path = "vendor/vello_common/assets/probe.png"
|
||||
title = "vello_common probe reference image (rendered from probe.rgba)"
|
||||
author = "Linebender vello contributors"
|
||||
source = "vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
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"
|
||||
Generated
+128
-39
@@ -1142,10 +1142,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f03ec27ab2c405969428512178f746183cd55ca0#f03ec27ab2c405969428512178f746183cd55ca0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
@@ -1154,6 +1159,18 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1184,6 +1201,15 @@ dependencies = [
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -2067,6 +2093,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontconfig-parser"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -3005,6 +3040,16 @@ dependencies = [
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -3248,6 +3293,24 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -3875,8 +3938,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-quit",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
@@ -3884,6 +3948,7 @@ dependencies = [
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-ui-egui",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rustls-native-certs",
|
||||
"serde_json",
|
||||
"uds_windows",
|
||||
@@ -3891,13 +3956,14 @@ dependencies = [
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3909,7 +3975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -3920,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3931,7 +3997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -3952,13 +4018,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
"pdfcraft-automation",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-preflight",
|
||||
"pdfcraft-render",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
@@ -3967,22 +4035,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-crypt",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-oracle",
|
||||
"pdfcraft-testkit",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -3990,7 +4060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4006,7 +4076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4018,7 +4088,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4033,9 +4103,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"icu_properties",
|
||||
"idna",
|
||||
"image",
|
||||
@@ -4065,19 +4136,21 @@ dependencies = [
|
||||
"pdfcraft-xfdf",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4087,7 +4160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4097,7 +4170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4109,37 +4182,38 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-testkit",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4149,7 +4223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4160,36 +4234,43 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-oracle",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
"rustix 1.1.5",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4200,7 +4281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4209,12 +4290,14 @@ dependencies = [
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-testkit",
|
||||
"pdfcraft-xfa",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4233,7 +4316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4265,18 +4348,24 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"fontconfig-parser",
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -4309,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4321,7 +4410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4335,7 +4424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
@@ -5398,9 +5487,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -6868,8 +6957,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
@@ -7946,6 +8033,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lopdf",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-oracle",
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+40
-33
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -14,40 +14,46 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
|
||||
# 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.7.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.7.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.7.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.7.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.7.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.7.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.7.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.7.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.7.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.7.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.7.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.7.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.7.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.7.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.7.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.7.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.7.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.7.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.7.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.7.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.7.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.7.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.7.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.7.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.7.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.7.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.7.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.7.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.7.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.7.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.7.0", default-features = false }
|
||||
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 }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
@@ -110,3 +116,4 @@ hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
winit = { path = "vendor/winit" }
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
@@ -11,7 +11,7 @@
|
||||
<h1 align="center">PdfCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.</b><br>
|
||||
<b>The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.</b><br>
|
||||
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.<br>
|
||||
macOS · Windows · Linux · FreeBSD · the web
|
||||
</p>
|
||||
@@ -172,7 +172,7 @@ Open **Organize pages** to see every page at once:
|
||||
**Saves you can trust:**
|
||||
- *Incremental:* the original bytes stay byte-for-byte intact.
|
||||
- *Atomic:* the file is written to a temporary copy, then swapped in.
|
||||
- *Verified:* independently checked with qpdf.
|
||||
- *Verified:* independently checked with qpdf, on every CI run.
|
||||
|
||||
When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view.
|
||||
|
||||
@@ -199,7 +199,7 @@ Every page of a combined document renders pixel-identical to its source.
|
||||
|
||||
```sh
|
||||
pdfcraft-cli combine report.pdf appendix.pdf --out combined.pdf
|
||||
pdfcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf
|
||||
pdfcraft-cli extract report.pdf --pages 1,3,5-8 --out highlights.pdf
|
||||
pdfcraft-cli split report.pdf --every 10 --out-dir parts/
|
||||
```
|
||||
|
||||
@@ -285,6 +285,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch
|
||||
pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON
|
||||
pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"}
|
||||
pdfcraft-cli run --script steps.json --root ./work # several steps in one session
|
||||
pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error
|
||||
```
|
||||
|
||||
With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it).
|
||||
@@ -365,7 +366,8 @@ On top of those, two corpus sweeps run over real-world files:
|
||||
- **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes.
|
||||
- **Open, edit and save round trips:** 958 succeed.
|
||||
|
||||
The output is verified with independent tools: hayro, qpdf and poppler.
|
||||
The output is verified against independent implementations: hayro and qpdf on every CI run, and
|
||||
poppler over the corpus on demand (`cargo xtask text-oracle`).
|
||||
|
||||
PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
@@ -415,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development
|
||||
|
||||
## What's next
|
||||
|
||||
PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro.
|
||||
PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows.
|
||||
|
||||
**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout.
|
||||
**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout.
|
||||
|
||||
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
|
||||
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
|
||||
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
|
||||
- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
|
||||
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
|
||||
- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side.
|
||||
|
||||
**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
|
||||
**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish.
|
||||
|
||||
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
@@ -451,17 +453,40 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no
|
||||
`%APPDATA%`; delete that file to use the normal per-user folders.
|
||||
|
||||
The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft"
|
||||
to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"),
|
||||
without changing which app opens PDFs by default. For unattended deployment without a desktop
|
||||
shortcut, run from an elevated terminal:
|
||||
to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
|
||||
images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
|
||||
"Show more options"), without changing which app opens them by default. For unattended deployment
|
||||
without a desktop shortcut, run from an elevated terminal:
|
||||
|
||||
```powershell
|
||||
msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0
|
||||
```
|
||||
|
||||
Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too,
|
||||
since each install applies its own choices. Use the MSI for your architecture. Per-user
|
||||
installation overrides are not supported.
|
||||
| Property | `=0` leaves out |
|
||||
|---|---|
|
||||
| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut |
|
||||
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
|
||||
| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
|
||||
|
||||
All three are on for a first install. An upgrade keeps what the installed version has: a shortcut
|
||||
or menu entry that is there stays, and one that is missing (left out at install, or a desktop
|
||||
shortcut someone deleted) stays missing, so you don't need to pass them again. A value on the
|
||||
command line always wins: `=1` puts one back and `=0` removes it. Upgrading from a release that
|
||||
didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier for the image menu)
|
||||
adds it, as a first install would. Use the MSI for your architecture. Per-user installation
|
||||
overrides are not supported.
|
||||
|
||||
**Pin the checksum, not just the version.** Release candidates of a version share its MSI version
|
||||
(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an
|
||||
MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't
|
||||
be told apart by version, so if your deployment tool checks the file, pin its SHA-256 from
|
||||
`SHA256SUMS.txt` (`Get-FileHash <file> -Algorithm SHA256`).
|
||||
|
||||
**Signing identities.** The Windows MSIs and both executables are signed through Azure Trusted
|
||||
Signing with a certificate issued to `Learning Machines Inc` (Atlanta, Georgia, US). The macOS app
|
||||
and command-line tool are signed with the Developer ID `Learning Machines LLC (DJ6XS33FX8)` and
|
||||
notarized. The MSI's manufacturer, shown as the publisher in Installed apps, is
|
||||
`Learning Machines LLC`.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -556,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+145
-11
@@ -1,8 +1,29 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3 #1577 branch refresh: upstream integrated, Vietnamese label added, scoped checks and visual comparison pass; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1577: optional page-edge shadows and viewport-edge fades; native-opening 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)
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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: AcroForm XML text; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; 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 (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; 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 (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-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-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)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; 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: Extract and Split preserve shared Type 3 glyph resources; 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 (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -10,6 +31,7 @@
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; 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 (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; 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 (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; 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 (M5: comment submission shortcuts; 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 (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -19,6 +41,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; 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 (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)
|
||||
|
||||
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
|
||||
@@ -47,7 +70,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
|
||||
| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
|
||||
| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
|
||||
| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
|
||||
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
|
||||
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated |
|
||||
@@ -64,7 +87,7 @@ and 245 open issues keep it far from beta.
|
||||
|
||||
| Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
|
||||
| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
|
||||
| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog |
|
||||
| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs |
|
||||
|
||||
@@ -79,13 +102,13 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef
|
||||
| File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) |
|
||||
| Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | ≈ 68% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 |
|
||||
| Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 |
|
||||
| Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) |
|
||||
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) |
|
||||
| AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 |
|
||||
| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | |
|
||||
| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -110,8 +133,8 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
|
||||
|
||||
## Languages
|
||||
|
||||
Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes:
|
||||
[localization-parity.md](docs/localization-parity.md).
|
||||
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).
|
||||
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
@@ -126,10 +149,11 @@ Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script no
|
||||
| Japanese | `ja` | 99.1% | partial |
|
||||
| German | `de` | 99.3% | partial |
|
||||
| Korean | `ko` | 0% | none |
|
||||
| Vietnamese | `vi` | 0% | none |
|
||||
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian
|
||||
(94–100%) and Czech (menus only): **8 more languages**.
|
||||
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).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -149,10 +173,93 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-18 (M3, #1577 refresh):** Integrated upstream `4db6ac4` while preserving selection scrolling, Organize page duplication and persistent redaction preferences. Added the shadow-setting label to the newer Vietnamese catalog; translation hashes refreshed. 181 scoped tests, strict UI all-target Clippy, UI WASM, formatting, assets, layering and parity pass; enabled/disabled renders inspected. Hosted CI must verify the updated PR head.
|
||||
|
||||
- 2026-10-13 — M3, #1577: optional app-wide page-edge shadows and viewport-edge fades in the viewer and Organize grid; enabled by default, persisted and controllable through the UI channel. PDF rendering and edits are 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-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.
|
||||
|
||||
- **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-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.
|
||||
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace 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-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-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 (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-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-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-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-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-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.
|
||||
- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged.
|
||||
- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged.
|
||||
- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged.
|
||||
- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged.
|
||||
- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged.
|
||||
- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged.
|
||||
- **2026-10-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). Estimates unchanged.
|
||||
- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged.
|
||||
|
||||
- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged.
|
||||
- **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged.
|
||||
- **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182.
|
||||
- **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given.
|
||||
- **2026-10-14 (M3 #2080 candidate):** Existing per-tab page history gains explicit search jumps, rail buttons and control-channel commands. Continuous scrolling and automatic search refresh stay outside history; page-level restoration and estimates unchanged.
|
||||
|
||||
- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged.
|
||||
- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged.
|
||||
- **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix.
|
||||
- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged.
|
||||
- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged.
|
||||
- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged.
|
||||
- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged.
|
||||
- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged.
|
||||
- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged.
|
||||
- **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged.
|
||||
|
||||
- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned.
|
||||
- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned.
|
||||
- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions.
|
||||
- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening.
|
||||
- **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-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.
|
||||
- **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
|
||||
- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged.
|
||||
- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged.
|
||||
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
|
||||
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
|
||||
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
|
||||
@@ -164,6 +271,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged.
|
||||
- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged.
|
||||
- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M5, comment shortcuts):** Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds a newline. Replies keep Enter-to-post, and floating comment composers only submit from their focused editor. Estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M7, #1443):** Adding a word to a multi-line paragraph no longer reflows or merges its lines. Two causes: in Chrome- and Skia-made subset fonts, `/W` leaves out glyphs of the `/DW` width, which were treated as missing, so unchanged rows measured too wide and rewrapped (and new letters fell back to Helvetica); and lines drawn turned on the page (a landscape page drawn a quarter turn round) were compared along the page's x axis, so all of them became one line. Lines and rows are now found along each text's own baseline, a turned paragraph is rewritten in its own text space, and that rewrite keeps explicit line breaks. Formatting part of turned text is refused rather than misplaced. Synthetic regressions cover three turns and a default-width subset. Estimates unchanged.
|
||||
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
|
||||
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
|
||||
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
|
||||
@@ -177,6 +287,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
|
||||
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
|
||||
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
|
||||
- **2026-10-12 (M1, #1337):** COS opening repairs exhausted cross-reference entries and records the skipped row; allocation safely falls back to unused numbers for exhausted /Size hints and high edits. Classic-table and stream regressions reproduce the former panic; ordinary, zero-offset and repaired-range controls preserve opening and allocation. Estimates unchanged.
|
||||
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
|
||||
@@ -202,6 +313,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
|
||||
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
|
||||
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
|
||||
- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged.
|
||||
|
||||
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
|
||||
@@ -222,6 +334,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged.
|
||||
|
||||
- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged.
|
||||
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
|
||||
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
|
||||
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
|
||||
@@ -427,8 +540,24 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-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-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. |
|
||||
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. |
|
||||
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged |
|
||||
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
|
||||
@@ -445,5 +574,10 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
|
||||
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
|
||||
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
|
||||
| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. |
|
||||
|
||||
- **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags.
|
||||
- **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.
|
||||
@@ -18,7 +18,9 @@ serde_json = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-audit = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-preflight = { workspace = true }
|
||||
pdfcraft-automation = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "report.pdf".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--compact", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("report.pdf"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--root"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
+205
-33
@@ -17,6 +17,8 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod automation_args;
|
||||
mod batch;
|
||||
|
||||
use std::io::Write;
|
||||
@@ -33,8 +35,14 @@ use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, in
|
||||
macro_rules! mcp_usage {
|
||||
() => {
|
||||
"\
|
||||
pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in)
|
||||
pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
MCP server on stdin/stdout (opt-in)
|
||||
--compact lists a core set of tools plus tool_search and tool_call
|
||||
--automation-read-root / --automation-write-root confine the files the
|
||||
agent's tools read (open, combine, import, fonts, certificates…) and
|
||||
write (save, export, extract, split, reports…) to DIR, links followed;
|
||||
a root left out grants none of its access. --root, when also given,
|
||||
applies as well
|
||||
"
|
||||
};
|
||||
}
|
||||
@@ -49,13 +57,14 @@ macro_rules! mcp_usage {
|
||||
const USAGE: &str = concat!(
|
||||
"\
|
||||
pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
|
||||
(includes any declared PDF/A or PDF/UA level)
|
||||
pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
|
||||
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||||
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
|
||||
[--insert-blank 1] [--title T] [--author A] [--full]
|
||||
(options run in order; page numbers refer to the document after the earlier options)
|
||||
pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
||||
pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||||
pdfcraft-cli extract <in.pdf> --pages 1,3,5-8 --out out.pdf (pages and ranges, in the order given)
|
||||
pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||||
pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||||
pdfcraft-cli audit <file.pdf> [--password PW] find faux redactions (fails when found)
|
||||
@@ -64,7 +73,8 @@ pdfcraft-cli batch <files or dirs...> --steps convert,ocr,optimize --out-dir DIR
|
||||
[--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages]
|
||||
pdfcraft-cli tools automation tools and their JSON Schemas
|
||||
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
|
||||
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
|
||||
pdfcraft-cli run --script steps.json [--root DIR] [--report]
|
||||
[{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run
|
||||
",
|
||||
mcp_usage!(),
|
||||
"\
|
||||
@@ -161,15 +171,20 @@ fn audit(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("audit", args, AUDIT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("audit: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
|
||||
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 items: Vec<serde_json::Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let rect = f.rect.map(|v| v as f32);
|
||||
serde_json::json!({
|
||||
"page": f.page + 1,
|
||||
"kind": f.kind.id(),
|
||||
"rect": f.rect,
|
||||
"rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
|
||||
"covered_text": f.covered_text,
|
||||
})
|
||||
})
|
||||
@@ -251,6 +266,7 @@ const RUN_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--root", takes_value: true },
|
||||
OptionSpec { name: "--script", takes_value: true },
|
||||
OptionSpec { name: "--out", takes_value: true },
|
||||
OptionSpec { name: "--report", takes_value: false },
|
||||
];
|
||||
const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
|
||||
|
||||
@@ -283,28 +299,45 @@ fn info(args: &[String]) -> Result<(), CliError> {
|
||||
let path = *positional(args).first().ok_or("info: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
let mut info = inspect(bytes.clone(), password).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).
|
||||
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
|
||||
.then(|| {
|
||||
let mut s = pdfcraft_engine::Session::new();
|
||||
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
|
||||
let d = s.get(id)?;
|
||||
d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }))
|
||||
d.xfa.as_ref().map(|x| {
|
||||
info = d.info.clone();
|
||||
// The in-memory layout is an appended revision, not the file being inspected.
|
||||
info.file_size = bytes.len();
|
||||
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
// What the document declares as a standard, read the way the Standards panel reads it, so
|
||||
// `info` and the app agree. A file that declares nothing leaves these null and empty.
|
||||
let declared =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
|
||||
let json = serde_json::json!({
|
||||
"file": path,
|
||||
"pdf_version": info.pdf_version,
|
||||
"file_size": info.file_size,
|
||||
"pages": info.pages.len(),
|
||||
"pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
|
||||
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
|
||||
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
|
||||
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
|
||||
"pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
|
||||
"pdfua": declared.pdfua,
|
||||
"output_intents": declared.output_intents,
|
||||
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||||
"xfa_layout": xfa_layout,
|
||||
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
|
||||
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
|
||||
"transitions": info.transitions.len(),
|
||||
"custom_properties": info.custom.len(),
|
||||
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
|
||||
"warnings": info.warnings,
|
||||
});
|
||||
@@ -332,7 +365,19 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
}
|
||||
let password = flag(args, "--password");
|
||||
let bytes = read(path)?;
|
||||
let mut bytes = read(path)?;
|
||||
// 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();
|
||||
if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
|
||||
// The engine's hint is about editing in the app; on the command line the way in is
|
||||
// `--password`.
|
||||
doc.check_copying()
|
||||
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
|
||||
if doc.xfa.is_some() {
|
||||
bytes = doc.display.clone();
|
||||
}
|
||||
}
|
||||
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.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
|
||||
@@ -343,15 +388,6 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
return Err("text: the document could not be parsed".into());
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
|
||||
// The engine's hint is about editing in the app; on the command line the way in is
|
||||
// `--password`.
|
||||
doc.check_copying()
|
||||
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
|
||||
}
|
||||
let pages: Vec<usize> = match selected_page {
|
||||
Some(page) => vec![page],
|
||||
None => (0..r.page_count()).collect(),
|
||||
@@ -376,9 +412,30 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-based page list ("1,3,5") → 0-based indices.
|
||||
/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
|
||||
const MAX_RANGE: usize = 1_000_000;
|
||||
|
||||
/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
|
||||
/// indices (#1923).
|
||||
fn page_list(s: &str) -> Result<Vec<usize>, String> {
|
||||
s.split(',').map(|p| p.trim().parse::<usize>().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect()
|
||||
let number = |t: &str, item: &str| t.trim().parse::<usize>().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
|
||||
let mut out = Vec::new();
|
||||
for item in s.split(',').map(str::trim) {
|
||||
match item.split_once('-') {
|
||||
Some((from, to)) => {
|
||||
let (from, to) = (number(from, item)?, number(to, item)?);
|
||||
if to < from {
|
||||
return Err(format!("bad page range {item:?}: it ends before it starts"));
|
||||
}
|
||||
if to - from >= MAX_RANGE {
|
||||
return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
|
||||
}
|
||||
out.extend(from - 1..to);
|
||||
}
|
||||
None => out.push(number(item, item)? - 1),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The options `edit` understands, for its "unknown option" message.
|
||||
@@ -469,7 +526,16 @@ fn edit(args: &[String]) -> Result<(), CliError> {
|
||||
session.apply(id, e).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
|
||||
// As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
|
||||
session.check_save(id, &bytes).map_err(|e| e.to_string())?;
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
|
||||
/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
|
||||
fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
|
||||
use pdfcraft_platform::staging::{StagingName, write_atomically};
|
||||
write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
@@ -485,7 +551,7 @@ fn combine(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::<Result<Vec<_>, String>>()?;
|
||||
let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
@@ -496,7 +562,7 @@ fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||||
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
fn split(args: &[String]) -> Result<(), CliError> {
|
||||
@@ -518,7 +584,7 @@ fn split(args: &[String]) -> Result<(), CliError> {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
|
||||
for (a, b, bytes) in parts {
|
||||
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
|
||||
std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?;
|
||||
write_pdf(&name, &bytes)?;
|
||||
stdout_line(format_args!("{}", name.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -539,16 +605,25 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
Some("pam") => None,
|
||||
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
|
||||
};
|
||||
let mut r = PageRenderer::new(
|
||||
read(path)?,
|
||||
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
|
||||
);
|
||||
// The app renders a display copy that draws comments missing their /AP streams; the CLI
|
||||
// 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)) {
|
||||
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 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());
|
||||
}
|
||||
let bytes = match format {
|
||||
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?,
|
||||
// With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
|
||||
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
|
||||
None => {
|
||||
// PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
|
||||
let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
|
||||
@@ -757,22 +832,88 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_au
|
||||
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
|
||||
stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
|
||||
}
|
||||
None => stdout_line(format_args!(
|
||||
"{}",
|
||||
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
|
||||
))?,
|
||||
None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a PNG result is reported as when it isn't written to a file.
|
||||
fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
|
||||
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
|
||||
}
|
||||
|
||||
/// A tool's result as JSON values, for the `--report` of a script. Same content as
|
||||
/// [`print_output`], collected instead of printed.
|
||||
fn output_values(
|
||||
auto: &pdfcraft_automation::Automation,
|
||||
content: Vec<pdfcraft_automation::Content>,
|
||||
out: Option<&str>,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let mut values = Vec::new();
|
||||
for c in content {
|
||||
values.push(match c {
|
||||
pdfcraft_automation::Content::Json(v) => v,
|
||||
pdfcraft_automation::Content::Png { data, width, height } => match out {
|
||||
Some(path) => {
|
||||
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
|
||||
serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
|
||||
}
|
||||
None => png_summary(&data, width, height),
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
|
||||
/// with the result or the message. The first failing step ends the run, as without `--report`;
|
||||
/// the steps after it are listed as skipped and the exit status is the same.
|
||||
fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
|
||||
let mut report = Vec::with_capacity(steps.len());
|
||||
let mut failure: Option<String> = None;
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let tool = step["tool"].as_str();
|
||||
if failure.is_some() {
|
||||
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
|
||||
continue;
|
||||
}
|
||||
let outcome = match tool {
|
||||
None => Err(format!("step {n}: missing \"tool\"")),
|
||||
Some(t) => auto
|
||||
.call(t, &step["args"])
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|content| output_values(auto, content, step["out"].as_str()))
|
||||
.map_err(|e| format!("step {n} ({t}): {e}")),
|
||||
};
|
||||
match outcome {
|
||||
Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
|
||||
Err(message) => {
|
||||
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
|
||||
failure = Some(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
|
||||
failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("run", args, RUN_OPTIONS)?;
|
||||
let report = args.iter().any(|a| a == "--report");
|
||||
if report && flag(args, "--script").is_none() {
|
||||
return Err("run: --report needs --script".to_string().into());
|
||||
}
|
||||
let mut auto = automation(args)?;
|
||||
if let Some(script) = flag(args, "--script") {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let steps: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
|
||||
if report {
|
||||
return run_script_report(&mut auto, &steps);
|
||||
}
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
|
||||
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
|
||||
@@ -794,11 +935,42 @@ fn run(args: &[String]) -> Result<(), CliError> {
|
||||
print_output(&auto, content, flag(args, "--out"))
|
||||
}
|
||||
|
||||
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
|
||||
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
|
||||
/// that are left. A malformed root flag is an error, never a root silently left out.
|
||||
#[cfg(feature = "mcp")]
|
||||
fn automation_roots(command: &str, args: &[String]) -> Result<(Option<Arc<pdfcraft_automation::file_access::AutomationRoots>>, Vec<String>), String> {
|
||||
let mut found = automation_args::AutomationArgs::new(None, None);
|
||||
let mut left = Vec::new();
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(arg) = it.next() {
|
||||
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
|
||||
left.push(arg);
|
||||
}
|
||||
}
|
||||
let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
|
||||
.map_err(|e| format!("{command}: {e}"))?;
|
||||
Ok((roots, left))
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
fn mcp(args: &[String]) -> Result<(), CliError> {
|
||||
let (roots, args) = automation_roots("mcp", args)?;
|
||||
let args = args.as_slice();
|
||||
validate_options("mcp", args, MCP_OPTIONS)?;
|
||||
let compact = args.iter().any(|a| a == "--compact");
|
||||
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact);
|
||||
let mut auto = automation(args)?;
|
||||
if let Some(roots) = roots {
|
||||
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: files confined: read root {}, write root {}",
|
||||
show(roots.read_root()),
|
||||
show(roots.write_root())
|
||||
);
|
||||
auto = auto.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! `audit` reports the rectangles of faux redactions where `doc_audit_redactions` does: in
|
||||
//! points from the top-left of the page as displayed, after `/Rotate`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// Two US Letter pages with "TOP SECRET" under an opaque `90 688 220 32 re`; the second page is
|
||||
/// turned a quarter (`/Rotate 90`).
|
||||
fn fixture() -> Vec<u8> {
|
||||
let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// The `rect` of each finding, in page order.
|
||||
fn rects(report: &serde_json::Value) -> Vec<serde_json::Value> {
|
||||
report["findings"].as_array().expect("findings").iter().map(|f| f["rect"].clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_rectangles_match_the_tool_in_top_left_points() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-audit-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("faux.pdf"), fixture()).unwrap();
|
||||
|
||||
let out = Command::new(BIN).arg("audit").arg(dir.join("faux.pdf")).output().unwrap();
|
||||
assert!(!out.status.success(), "findings must fail the audit");
|
||||
let cli: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(cli["count"], 2, "{cli}");
|
||||
// Page 1 upright: y measured down from the top of the 792 pt page. Page 2 turned a quarter
|
||||
// clockwise: 792 pt wide, and the cover's user-space y becomes its x.
|
||||
let expected = [serde_json::json!([90.0, 72.0, 310.0, 104.0]), serde_json::json!([688.0, 90.0, 720.0, 310.0])];
|
||||
assert_eq!(rects(&cli), expected, "{cli}");
|
||||
|
||||
// The tool reports the same rectangles.
|
||||
let steps = serde_json::json!([
|
||||
{ "tool": "doc_open", "args": { "path": "faux.pdf" } },
|
||||
{ "tool": "doc_audit_redactions", "args": { "doc": 1 } },
|
||||
]);
|
||||
std::fs::write(dir.join("steps.json"), steps.to_string()).unwrap();
|
||||
let out = Command::new(BIN).arg("run").arg("--script").arg(dir.join("steps.json")).arg("--root").arg(&dir).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let tool = serde_json::Deserializer::from_str(&stdout).into_iter::<serde_json::Value>().last().unwrap().unwrap();
|
||||
assert_eq!(rects(&tool), rects(&cli), "{tool}");
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! `pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, on the real binary:
|
||||
//! both spellings of the flags confine the server, and a malformed flag stops it instead of
|
||||
//! leaving it unconfined.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// `in/a.pdf` and `outside/secret.pdf`, blank one-page PDFs made by the binary itself.
|
||||
fn folders(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-cli-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for f in ["in/a.pdf", "outside/secret.pdf"] {
|
||||
let script = json!([
|
||||
{"tool": "doc_create", "args": {"from": "blank", "pages": 1, "width": 100, "height": 100}},
|
||||
{"tool": "doc_save", "args": {"doc": 1, "path": f}},
|
||||
]);
|
||||
std::fs::write(base.join("steps.json"), script.to_string()).unwrap();
|
||||
let out = Command::new(BIN).args(["run", "--script"]).arg(base.join("steps.json")).arg("--root").arg(&base).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
/// Run `mcp <flags>`, send `calls` as `tools/call` requests and return the replies.
|
||||
fn session(flags: &[String], calls: &[(&str, Value)]) -> (Vec<Value>, String) {
|
||||
let mut child = Command::new(BIN).arg("mcp").args(flags).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut input = String::from("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n");
|
||||
for (i, (name, args)) in calls.iter().enumerate() {
|
||||
let r = json!({"jsonrpc": "2.0", "id": i + 1, "method": "tools/call", "params": {"name": name, "arguments": args}});
|
||||
input.push_str(&format!("{r}\n"));
|
||||
}
|
||||
child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let replies = String::from_utf8(out.stdout).unwrap().lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(replies, String::from_utf8(out.stderr).unwrap())
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn flags(spelling: &str, base: &Path) -> Vec<String> {
|
||||
let (r, w) = (s(&base.join("in")), s(&base.join("out")));
|
||||
if spelling == "equals" {
|
||||
vec![format!("--automation-read-root={r}"), format!("--automation-write-root={w}")]
|
||||
} else {
|
||||
vec!["--automation-read-root".into(), r, "--automation-write-root".into(), w]
|
||||
}
|
||||
}
|
||||
|
||||
fn text(reply: &Value) -> String {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_spellings_confine_the_server() {
|
||||
for spelling in ["equals", "separate"] {
|
||||
let base = folders(spelling);
|
||||
let calls = [
|
||||
("doc_open", json!({"path": s(&base.join("in/a.pdf"))})),
|
||||
("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))})),
|
||||
("doc_save", json!({"doc": 1, "path": s(&base.join("out/copy.pdf"))})),
|
||||
("doc_save", json!({"doc": 1, "path": s(&base.join("outside/copy.pdf"))})),
|
||||
];
|
||||
let (replies, stderr) = session(&flags(spelling, &base), &calls);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{spelling}: {replies:?}");
|
||||
assert!(text(&replies[1]).contains("automation path rejected: outside the read root"), "{spelling}: {replies:?}");
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{spelling}: {replies:?}");
|
||||
assert!(text(&replies[3]).contains("automation path rejected: outside the write root"), "{spelling}: {replies:?}");
|
||||
assert!(base.join("out/copy.pdf").is_file() && !base.join("outside/copy.pdf").exists());
|
||||
assert!(stderr.contains("files confined: read root") && stderr.contains("write root"), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let base = folders("one");
|
||||
let read_only = vec!["--automation-read-root".to_string(), s(&base.join("in"))];
|
||||
let calls = [("doc_open", json!({"path": s(&base.join("in/a.pdf"))})), ("doc_save", json!({"doc": 1, "path": s(&base.join("out/x.pdf"))}))];
|
||||
let (replies, _) = session(&read_only, &calls);
|
||||
assert_eq!(replies[0]["result"]["isError"], false);
|
||||
assert!(text(&replies[1]).contains("write authority is absent"), "{replies:?}");
|
||||
assert!(!base.join("out/x.pdf").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_flags_means_no_confinement() {
|
||||
let base = folders("none");
|
||||
let (replies, stderr) = session(&[], &[("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}))]);
|
||||
assert_eq!(replies[0]["result"]["isError"], false);
|
||||
assert!(!stderr.contains("confined"), "{stderr}");
|
||||
}
|
||||
|
||||
fn fails(args: &[&str]) -> String {
|
||||
let out: Output = Command::new(BIN).arg("mcp").args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?} should fail");
|
||||
assert!(out.stdout.is_empty(), "{args:?} must not start a server");
|
||||
String::from_utf8(out.stderr).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_flags_are_errors_not_a_missing_root() {
|
||||
let dir = s(&std::env::temp_dir());
|
||||
assert!(fails(&["--automation-read-root"]).contains("--automation-read-root needs a folder"));
|
||||
assert!(fails(&["--automation-write-root", "--compact"]).contains("--automation-write-root needs a folder; use --automation-write-root=<dir>"));
|
||||
assert!(fails(&["--automation-read-root="]).contains("--automation-read-root needs a non-empty folder"));
|
||||
assert!(fails(&["--automation-read-root", &dir, "--automation-read-root", &dir]).contains("--automation-read-root given twice"));
|
||||
assert!(fails(&["--automation-write-root", &dir, &format!("--automation-write-root={dir}")]).contains("--automation-write-root given twice"));
|
||||
let missing = fails(&["--automation-read-root", "/definitely/not/a/folder"]);
|
||||
assert!(missing.contains("mcp: cannot open the automation read root"), "{missing}");
|
||||
let file = std::env::current_exe().unwrap();
|
||||
assert!(fails(&["--automation-write-root", &s(&file)]).contains("cannot open the automation write root"));
|
||||
}
|
||||
@@ -92,6 +92,20 @@ fn the_documented_options_still_edit_and_save() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--out` naming the input replaces it through a staging file (#2166): the result is the
|
||||
/// edited, reopenable file, and nothing is left beside it.
|
||||
#[test]
|
||||
fn editing_in_place_leaves_a_valid_file_and_no_staging_file() {
|
||||
let input = tmp("in-place", "doc.pdf");
|
||||
std::fs::write(&input, fixture()).unwrap();
|
||||
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", input.to_str().unwrap(), "--rotate", "1:90"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(first_page_rotation(&input), 90);
|
||||
let names: Vec<String> =
|
||||
std::fs::read_dir(input.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(names, ["doc.pdf"], "no staging file beside the result");
|
||||
}
|
||||
|
||||
mod one_based_arguments {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -225,6 +239,102 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two pages whose catalog points at a page tree object the file does not have (#1656). The
|
||||
/// reader finds the pages by scanning, in file order, so `edit` must work on the same pages.
|
||||
/// Object 3 is written before object 2, so file order and object numbers disagree.
|
||||
fn fixture_without_page_tree() -> Vec<u8> {
|
||||
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
|
||||
let objs: Vec<(usize, Vec<u8>)> = vec![
|
||||
(1, b"<< /Type /Catalog /Pages 6 0 R >>".to_vec()),
|
||||
(3, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>".to_vec()),
|
||||
(2, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 300 200] /Contents 5 0 R >>".to_vec()),
|
||||
(4, stream("0 0 m 10 10 l S")),
|
||||
(5, stream("0 0 m 20 20 l S")),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = vec![0; objs.len()];
|
||||
for (num, o) in &objs {
|
||||
offsets[num - 1] = out.len();
|
||||
out.extend_from_slice(format!("{num} 0 obj\n").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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_whose_page_tree_is_missing_edits_the_pages_the_reader_shows() {
|
||||
let input = tmp("no-page-tree", "in.pdf");
|
||||
std::fs::write(&input, fixture_without_page_tree()).unwrap();
|
||||
let before = pdfcraft_render::inspect(std::sync::Arc::new(fixture_without_page_tree()), None).unwrap();
|
||||
assert_eq!(before.pages.iter().map(|p| (p.width, p.height)).collect::<Vec<_>>(), [(200.0, 300.0), (300.0, 200.0)]);
|
||||
for (name, args, expected) in [
|
||||
// Width and height are as displayed: the turned page reads 200 x 300.
|
||||
("rotate.pdf", ["--rotate", "2:90"], &[(200.0, 300.0, 0), (200.0, 300.0, 90)][..]),
|
||||
("delete.pdf", ["--delete", "1"], &[(300.0, 200.0, 0)][..]),
|
||||
] {
|
||||
let output = tmp("no-page-tree", name);
|
||||
let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
|
||||
all.extend(args);
|
||||
let out = Command::new(BIN).args(&all).output().unwrap();
|
||||
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
|
||||
let pages: Vec<_> = info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect();
|
||||
assert_eq!(pages, expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`fixture_without_page_tree`], but the scanning reader shows different pages from the
|
||||
/// page objects: it skips the blank 200 x 200 page (no `/Contents`) and shows the Text
|
||||
/// annotation (which has `/Contents`) as a page. No page tree may be rebuilt from such a file.
|
||||
fn fixture_whose_scan_disagrees() -> Vec<u8> {
|
||||
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 9 0 R >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 200 200] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [7 0 R] >>".to_vec(),
|
||||
stream("0 0 m 10 10 l S"),
|
||||
stream("0 0 m 20 20 l S"),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (hello) >>".to_vec(),
|
||||
];
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_tree_is_not_rebuilt_when_the_reader_shows_other_pages() {
|
||||
let input = tmp("scan-disagrees", "in.pdf");
|
||||
std::fs::write(&input, fixture_whose_scan_disagrees()).unwrap();
|
||||
let shown = pdfcraft_render::inspect(std::sync::Arc::new(fixture_whose_scan_disagrees()), None).unwrap();
|
||||
assert_eq!(shown.pages.len(), 3);
|
||||
assert!(shown.pages.iter().all(|p| p.width != 200.0), "the reader must not show the blank page");
|
||||
let output = tmp("scan-disagrees", "out.pdf");
|
||||
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), "--delete", "2"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert_eq!(String::from_utf8_lossy(&out.stderr), "pdfcraft-cli: the document's page tree lists no pages: the file is damaged\n");
|
||||
assert!(!output.exists(), "no page may be deleted");
|
||||
}
|
||||
|
||||
/// A page past the end names its own option and the page count left by the earlier options (#1550).
|
||||
#[test]
|
||||
fn a_page_past_the_end_after_earlier_edits_names_that_option() {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! `extract --pages`: single pages and ranges (#1923), and an out-of-range page named as the
|
||||
//! user typed it (1-based; it was off by one in 0.7.1).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A PDF with `n` empty 200×300 pt pages.
|
||||
fn fixture(n: usize) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
|
||||
for _ in 0..n {
|
||||
objs.push(b"<< /Type /Page /Parent 2 0 R >>".to_vec());
|
||||
}
|
||||
let mut out = b"%PDF-1.7
|
||||
"
|
||||
.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
|
||||
",
|
||||
i + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(
|
||||
b"
|
||||
endobj
|
||||
",
|
||||
);
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"xref
|
||||
0 {}
|
||||
0000000000 65535 f
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for o in offsets {
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"{o:010} 00000 n
|
||||
"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"trailer
|
||||
<< /Size {} /Root 1 0 R >>
|
||||
startxref
|
||||
{xref}
|
||||
%%EOF
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh directory holding `five.pdf` (5 pages).
|
||||
fn workdir(test: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pdfcraft-cli-extract-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("five.pdf"), fixture(5)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn extract(dir: &std::path::Path, pages: &str) -> Output {
|
||||
let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_")));
|
||||
Command::new(BIN).args(["extract", dir.join("five.pdf").to_str().unwrap(), "--pages", pages, "--out", out.to_str().unwrap()]).output().unwrap()
|
||||
}
|
||||
|
||||
/// The page count `info` reports for the extracted file.
|
||||
fn pages_of(dir: &std::path::Path, pages: &str) -> usize {
|
||||
let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_")));
|
||||
let info = Command::new(BIN).args(["info", out.to_str().unwrap()]).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&info.stdout);
|
||||
let n = text.split("\"pages\":").nth(1).and_then(|s| s.trim().split(|c: char| !c.is_ascii_digit()).next()).unwrap_or("");
|
||||
n.parse().unwrap_or_else(|_| panic!("no page count in {text}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_and_ranges_are_accepted_in_the_order_given() {
|
||||
let dir = workdir("ranges");
|
||||
for (pages, count) in [("1-3", 3), ("5,1-2", 3), ("2-2", 1), ("1,3,5", 3), (" 4 - 5 , 1 ", 3)] {
|
||||
let out = extract(&dir, pages);
|
||||
assert!(out.status.success(), "{pages:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(pages_of(&dir, pages), count, "{pages:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_pages_and_ranges_are_named_as_typed() {
|
||||
let dir = workdir("bad");
|
||||
for (pages, what) in [
|
||||
("3-1", "bad page range \"3-1\""),
|
||||
("0", "bad page number \"0\""),
|
||||
("1-x", "bad page number \"1-x\""),
|
||||
("2-", "bad page number \"2-\""),
|
||||
("1-9999999", "bad page range \"1-9999999\""),
|
||||
] {
|
||||
let out = extract(&dir, pages);
|
||||
assert!(!out.status.success(), "{pages:?} succeeded");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains(what), "{pages:?}: {stderr}");
|
||||
}
|
||||
// A page past the end is named as the user counts it (1-based), as a single page and as the
|
||||
// first page a range reaches past the end.
|
||||
for (pages, missing) in [("6", "page 6 does not exist"), ("9", "page 9 does not exist"), ("4-7", "page 6 does not exist")] {
|
||||
let out = extract(&dir, pages);
|
||||
assert!(!out.status.success(), "{pages:?} succeeded");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains(missing), "{pages:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file
|
||||
//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no
|
||||
//! mention of PDF/A at all, though the app reads the same declaration and shows it.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A one-page PDF, with `metadata` as the catalog's XMP stream when given.
|
||||
fn fixture(metadata: Option<&str>) -> Vec<u8> {
|
||||
let content = b"0 0 0 rg 10 10 40 20 re f";
|
||||
let catalog = match metadata {
|
||||
Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(),
|
||||
None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
};
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
catalog.into_bytes(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(),
|
||||
format!("<< /Length {} >> stream\n", content.len()).into_bytes(),
|
||||
];
|
||||
objs[3].extend_from_slice(content);
|
||||
objs[3].extend_from_slice(b"\nendstream");
|
||||
if let Some(xmp) = metadata {
|
||||
let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes();
|
||||
stream.extend_from_slice(xmp.as_bytes());
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
objs.push(stream);
|
||||
}
|
||||
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 off in &offsets {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn xmp(part: &str, conformance: &str) -> String {
|
||||
format!(
|
||||
"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
|
||||
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
|
||||
<rdf:Description rdf:about=\"\" xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n\
|
||||
<pdfaid:part>{part}</pdfaid:part>\n <pdfaid:conformance>{conformance}</pdfaid:conformance>\n\
|
||||
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>"
|
||||
)
|
||||
}
|
||||
|
||||
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("f.pdf");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
json
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_names_the_declared_pdfa_level() {
|
||||
let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b");
|
||||
assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}");
|
||||
assert_eq!(declared["pdfua"], serde_json::Value::Null);
|
||||
assert_eq!(declared["output_intents"], serde_json::json!([]));
|
||||
// Lower-case conformance in the file still reads as the level's own spelling.
|
||||
assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_declares_nothing_for_a_plain_pdf() {
|
||||
let plain = info_of(&fixture(None), "plain");
|
||||
assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}");
|
||||
assert_eq!(plain["pdfua"], serde_json::Value::Null);
|
||||
// The rest of the summary is unaffected.
|
||||
assert_eq!(plain["pages"], serde_json::json!(1));
|
||||
assert_eq!(plain["pdf_version"], serde_json::json!("1.7"));
|
||||
}
|
||||
@@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() {
|
||||
assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_records_the_dpi_it_was_rendered_at() {
|
||||
// The engine's tests check each format; this checks that `--dpi` reaches the file.
|
||||
let pdf = fixture("dpi");
|
||||
let jpg = tmp("dpi", "p1.jpg");
|
||||
assert!(render(&pdf, &jpg).status.success());
|
||||
let bytes = std::fs::read(&jpg).unwrap();
|
||||
assert_eq!(&bytes[6..11], b"JFIF\0");
|
||||
assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pam_name_still_gets_the_netpbm_stream() {
|
||||
let pdf = fixture("pam");
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `pdfcraft-cli run --script --report`: one JSON document for the whole run (#1891).
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run_script(test: &str, steps: &serde_json::Value, extra: &[&str]) -> Output {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("steps.json");
|
||||
std::fs::write(&script, steps.to_string()).unwrap();
|
||||
let out =
|
||||
Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).current_dir(&dir).arg("run").arg("--script").arg(&script).args(extra).output().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
out
|
||||
}
|
||||
|
||||
fn blank() -> serde_json::Value {
|
||||
serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_run_is_one_document_with_a_result_per_step() {
|
||||
let steps = serde_json::json!([blank(), { "tool": "page_render", "args": { "doc": 1, "page": 1, "dpi": 10 } }]);
|
||||
let o = run_script("report-ok", &steps, &["--report"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
|
||||
assert_eq!(report["ok"], true);
|
||||
let steps = report["steps"].as_array().unwrap();
|
||||
assert_eq!(steps.len(), 2);
|
||||
assert_eq!((steps[0]["step"].clone(), steps[0]["tool"].clone(), steps[0]["status"].clone()), (1.into(), "doc_create".into(), "ok".into()));
|
||||
assert_eq!(steps[1]["result"][0]["image"], "png", "an image with no `out` is summarised: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_step_is_named_and_the_rest_are_skipped() {
|
||||
let steps = serde_json::json!([blank(), { "tool": "page_rotate", "args": { "doc": 1, "pages": [999], "degrees": 90 } }, { "tool": "doc_close", "args": { "doc": 1 } }]);
|
||||
let o = run_script("report-fail", &steps, &["--report"]);
|
||||
assert_eq!(o.status.code(), Some(1), "exit status is unchanged");
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(stderr.contains("step 2 (page_rotate)"), "{stderr}");
|
||||
let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
|
||||
assert_eq!(report["ok"], false);
|
||||
let steps = report["steps"].as_array().unwrap();
|
||||
assert_eq!(steps.iter().map(|s| s["status"].as_str().unwrap()).collect::<Vec<_>>(), ["ok", "error", "skipped"]);
|
||||
assert!(steps[1]["error"].as_str().unwrap().contains("step 2 (page_rotate)"), "{report}");
|
||||
assert_eq!(steps[2]["tool"], "doc_close");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_without_a_tool_is_an_error_step() {
|
||||
let o = run_script("report-notool", &serde_json::json!([{ "args": {} }]), &["--report"]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let report: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||
assert_eq!(report["steps"][0]["status"], "error");
|
||||
assert!(report["steps"][0]["tool"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_flag_the_output_is_unchanged() {
|
||||
let o = run_script("report-off", &serde_json::json!([blank()]), &[]);
|
||||
assert!(o.status.success());
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
assert!(!stdout.contains("\"steps\""), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flag_needs_a_script() {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["run", "doc_create", "--report"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--report needs --script"));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::Arc;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
fn fixture(test: &str) -> PathBuf {
|
||||
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><occur max="-1"/>
|
||||
<contentArea x="20pt" y="20pt" w="160pt" h="260pt"/>
|
||||
<medium short="200pt" long="300pt"/>
|
||||
</pageArea></pageSet>
|
||||
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
|
||||
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
|
||||
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
|
||||
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
|
||||
</subform>
|
||||
</subform></template></xdp:xdp>"#;
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("form.pdf");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn success(output: &Output) {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_reports_the_laid_out_pages_and_fields() {
|
||||
let path = fixture("info");
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
|
||||
success(&output);
|
||||
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages");
|
||||
assert_eq!(info["pages"], 2, "top-level page count must agree with the app");
|
||||
assert_eq!(info["fields"], 1);
|
||||
assert_eq!(info["pdf_pages"], 1);
|
||||
assert_eq!(info["acroform_fields"], 0);
|
||||
assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0]));
|
||||
assert_eq!(info["file_size"], original.len());
|
||||
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
|
||||
let path = fixture("text");
|
||||
let output = Command::new(BIN).arg("text").arg(&path).output().unwrap();
|
||||
success(&output);
|
||||
let text = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}");
|
||||
let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap();
|
||||
success(&output);
|
||||
let text = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_matches_the_engine_layout_on_the_second_page() {
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
let path = fixture("render");
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page")));
|
||||
let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default());
|
||||
let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(expected.error.is_none(), "{:?}", expected.error);
|
||||
assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink");
|
||||
let out = path.with_extension("pam");
|
||||
let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap();
|
||||
success(&output);
|
||||
let actual = std::fs::read(out).unwrap();
|
||||
let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height);
|
||||
assert!(actual.starts_with(header.as_bytes()));
|
||||
assert_eq!(&actual[header.len()..], &expected.rgba[..]);
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_laid_out_form_keeps_the_original_copy_permissions() {
|
||||
let path = fixture("permissions");
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "openme",
|
||||
owner_password: "boss",
|
||||
permissions: -17,
|
||||
encrypt_metadata: true,
|
||||
seed: [17; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||||
std::fs::write(&path, &original).unwrap();
|
||||
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying"));
|
||||
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap();
|
||||
success(&output);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page"));
|
||||
let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap();
|
||||
success(&output);
|
||||
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(info["pages"], 2);
|
||||
assert_eq!(info["encrypted"], true);
|
||||
assert_eq!(info["file_size"], original.len());
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
}
|
||||
@@ -70,6 +70,10 @@ fn main() {
|
||||
app.host_save = Some(bridge.save_fn());
|
||||
app.host_dirty = Some(bridge.dirty_fn());
|
||||
bridge.post_ready();
|
||||
} else if query_param("host").as_deref() == Some("parent") {
|
||||
eframe::web_sys::console::warn_1(
|
||||
&"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
|
||||
);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
@@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
|
||||
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
///
|
||||
/// Only the parent window on the app's own origin is heard or answered.
|
||||
/// Only the parent window on the app's own origin is heard or answered. With a parent on
|
||||
/// another origin there is no bridge: the app runs as if unframed and saves download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host {
|
||||
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
@@ -143,7 +148,9 @@ mod host {
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent
|
||||
/// is on another origin: messages only go to this origin, so the browser would drop
|
||||
/// every save without an error while the document showed as saved (#2167).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
@@ -151,6 +158,10 @@ mod host {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ log = { workspace = true }
|
||||
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
|
||||
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
|
||||
# window to close instead of winit's, which ends the process without asking.
|
||||
craft-quit = { workspace = true, features = ["menu"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
|
||||
@@ -36,10 +39,19 @@ fmv-macos-events = "=0.1.0"
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
# Whether the window is on Wayland, where it can't bring itself forward (src/single_instance.rs).
|
||||
# Already in the tree through winit and eframe.
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
|
||||
pollster = "1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
# Exercise the vendored Wayland scroll policy with the actual winit event type.
|
||||
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).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (a failed autosave, a page
|
||||
//! that would not render, a render worker that could not start, the panic hook's report) vanished.
|
||||
//! A launch from a desktop menu has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings dir>/logs/pdfcraft.log`, next to `app.ron` (see `settings_dir` in `main.rs`). Each
|
||||
//! `<data dir>/logs/pdfcraft.log` (the PdfCraft eframe storage directory, or portable data). Each
|
||||
//! start moves the previous log to `pdfcraft.1.log` (and that one to `.2`), so the log of a run
|
||||
//! that crashed survives the next launch.
|
||||
//!
|
||||
|
||||
+88
-17
@@ -37,6 +37,7 @@ mod logging;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod motion;
|
||||
mod reopen;
|
||||
mod settings;
|
||||
mod single_instance;
|
||||
mod text_scale;
|
||||
mod updates;
|
||||
@@ -57,14 +58,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256
|
||||
/// The app was called PrintCraft before; settings saved then are under this key.
|
||||
const LEGACY_STORAGE_KEY: &str = "printcraft";
|
||||
|
||||
/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode
|
||||
/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the
|
||||
/// app id; keep it under "PdfCraft".
|
||||
/// The preferences folder (`app.ron`, docs/development.md). In portable mode
|
||||
/// it is `PdfCraftData` beside the executable (#157). XDG desktops use the configuration base;
|
||||
/// other platforms retain eframe's PdfCraft folder. Logs and recovery stay in the data folder.
|
||||
fn settings_dir() -> Option<std::path::PathBuf> {
|
||||
if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() {
|
||||
return Some(dir.to_path_buf());
|
||||
let legacy = eframe::storage_dir("PdfCraft");
|
||||
match settings::directory(
|
||||
cfg!(all(unix, not(target_os = "macos"))),
|
||||
pdfcraft_ui_egui::portable::data_dir(),
|
||||
legacy.clone(),
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from),
|
||||
std::env::home_dir(),
|
||||
) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
log::warn!("could not migrate preferences to the XDG configuration folder; using the original location: {e}");
|
||||
legacy
|
||||
}
|
||||
}
|
||||
eframe::storage_dir("PdfCraft")
|
||||
}
|
||||
|
||||
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
|
||||
@@ -75,7 +86,7 @@ fn migrate_legacy_folders() {
|
||||
if pdfcraft_ui_egui::portable::data_dir().is_some() {
|
||||
return;
|
||||
}
|
||||
let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())];
|
||||
let mut moves = vec![(eframe::storage_dir("PrintCraft"), eframe::storage_dir("PdfCraft"))];
|
||||
// Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%.
|
||||
if cfg!(windows) {
|
||||
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
@@ -203,21 +214,27 @@ fn main() -> eframe::Result {
|
||||
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
|
||||
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
|
||||
migrate_legacy_folders();
|
||||
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
|
||||
// The log file stays in the data folder; opened after the arguments (so `--version` leaves
|
||||
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
|
||||
// Records logged until now are written to it first.
|
||||
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
|
||||
if let (Some(logger), Some(dir)) =
|
||||
(logger, pdfcraft_ui_egui::portable::data_dir().map(std::path::Path::to_path_buf).or_else(|| eframe::storage_dir("PdfCraft")))
|
||||
{
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
// Windows: a launch that only names files (an Outlook attachment, an Explorer double-click)
|
||||
// hands them to the PdfCraft already running and exits (#282, #317). Any option means the
|
||||
// caller wants this launch's own window.
|
||||
// Windows, Linux and the BSDs: a launch that only names files (an Outlook attachment, an
|
||||
// Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
|
||||
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
|
||||
// window.
|
||||
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
|
||||
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
|
||||
// Absolute for this instance too: links relative to a document resolve against its folder,
|
||||
// never the working directory.
|
||||
let files = single_instance::absolute(&files);
|
||||
let instance = match single_instance::claim(&files, may_hand_off) {
|
||||
single_instance::Claim::HandedOff => return Ok(()),
|
||||
single_instance::Claim::Primary(server) => Some(server),
|
||||
single_instance::Claim::Alone => None,
|
||||
@@ -332,6 +349,18 @@ struct Launch {
|
||||
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
|
||||
}
|
||||
|
||||
/// Whether the window is a native Wayland one (not X11 or XWayland).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn native_wayland(cc: &eframe::CreationContext<'_>) -> bool {
|
||||
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||
cc.display_handle().is_ok_and(|handle| matches!(handle.as_raw(), RawDisplayHandle::Wayland(_)))
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn native_wayland(_: &eframe::CreationContext<'_>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn app_creator<'a>(
|
||||
launch: Launch,
|
||||
started: Rc<Cell<bool>>,
|
||||
@@ -351,6 +380,7 @@ fn app_creator<'a>(
|
||||
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
|
||||
app.restore(&json);
|
||||
}
|
||||
app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage);
|
||||
app.integrated_titlebar = integrated;
|
||||
app.window_controls = window_controls;
|
||||
app.system_text_scale = text_scale::system();
|
||||
@@ -366,9 +396,14 @@ fn app_creator<'a>(
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
|
||||
// menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
|
||||
if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
|
||||
log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
|
||||
}
|
||||
}
|
||||
if let Some(server) = instance {
|
||||
app.os_events = Some(server.connect(&cc.egui_ctx));
|
||||
app.os_events = Some(server.connect(&cc.egui_ctx, native_wayland(cc)));
|
||||
}
|
||||
if let Some(file) = &control_file {
|
||||
let client = app.attach_control(&cc.egui_ctx);
|
||||
@@ -531,7 +566,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
// egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces.
|
||||
// A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in
|
||||
// Surface::configure before the app starts. Keep the renderer's other device requirements,
|
||||
// Surface::configure before the app starts. Keep the renderer's other compatible requirements,
|
||||
// but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters).
|
||||
let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
|
||||
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
@@ -572,8 +607,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
}
|
||||
|
||||
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
|
||||
/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements.
|
||||
/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent.
|
||||
fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits {
|
||||
required = required.or_worse_values_from(supported);
|
||||
required.max_texture_dimension_2d = supported.max_texture_dimension_2d;
|
||||
required
|
||||
}
|
||||
@@ -878,16 +914,51 @@ mod tests {
|
||||
fn surface_limits_preserve_other_requirements_and_never_overrequest() {
|
||||
for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
|
||||
for extent in [4096, 8192, 16384] {
|
||||
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() };
|
||||
let supported = eframe::wgpu::Limits {
|
||||
max_texture_dimension_2d: extent,
|
||||
max_bind_groups: 8,
|
||||
min_uniform_buffer_offset_alignment: 128,
|
||||
min_storage_buffer_offset_alignment: 128,
|
||||
..required.clone()
|
||||
};
|
||||
let enabled = super::surface_texture_limits(required.clone(), &supported);
|
||||
let mut expected = required.clone();
|
||||
expected.max_texture_dimension_2d = extent;
|
||||
assert_eq!(enabled, expected);
|
||||
assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
|
||||
assert!(enabled.check_limits(&supported));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_limits_fit_v3d_shader_variable_limit() {
|
||||
// Issue #2383: V3D supports 15 variables, below the default request of 16.
|
||||
let required = eframe::wgpu::Limits::default();
|
||||
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
|
||||
let enabled = super::surface_texture_limits(required.clone(), &supported);
|
||||
assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
|
||||
let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
|
||||
assert_eq!(enabled, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
|
||||
let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
|
||||
let supported = eframe::wgpu::Limits {
|
||||
max_texture_dimension_2d: 2048,
|
||||
max_bind_groups: 2,
|
||||
max_vertex_attributes: 8,
|
||||
min_uniform_buffer_offset_alignment: 512,
|
||||
min_storage_buffer_offset_alignment: 512,
|
||||
..required.clone()
|
||||
};
|
||||
let enabled = super::surface_texture_limits(required, &supported);
|
||||
assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
|
||||
// Larger minimum alignments are weaker capabilities, unlike maximum limits.
|
||||
assert_eq!(enabled, supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
|
||||
use eframe::egui_wgpu::winit::surface_fit;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Platform preference location, separate from logs and crash recovery.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static STAGING_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn directory(
|
||||
xdg: bool,
|
||||
portable: Option<&Path>,
|
||||
legacy: Option<PathBuf>,
|
||||
config_home: Option<PathBuf>,
|
||||
home: Option<PathBuf>,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
if let Some(portable) = portable {
|
||||
return Ok(Some(portable.to_path_buf()));
|
||||
}
|
||||
if !xdg {
|
||||
return Ok(legacy);
|
||||
}
|
||||
// XDG requires absolute environment paths; empty and relative overrides are ignored.
|
||||
let preferred = config_home.filter(|p| p.is_absolute()).or_else(|| home.filter(|p| p.is_absolute()).map(|p| p.join(".config")));
|
||||
let Some(preferred) = preferred.map(|p| p.join("pdfcraft")) else { return Ok(legacy) };
|
||||
let target = preferred.join("app.ron");
|
||||
if std::fs::symlink_metadata(&target).is_ok() {
|
||||
return Ok(Some(preferred));
|
||||
}
|
||||
create_private_directory(&preferred)?;
|
||||
if let Some(source) = legacy.map(|p| p.join("app.ron")) {
|
||||
match std::fs::File::open(&source) {
|
||||
Ok(mut source) => migrate(&mut source, &preferred, &target)?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(Some(preferred))
|
||||
}
|
||||
|
||||
/// Create private configuration directories without changing existing directory permissions.
|
||||
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
builder.create(directory)
|
||||
}
|
||||
|
||||
/// Publish a complete copy without replacing a concurrent launch's preferences. Keep the
|
||||
/// original file and all other data (especially recovery and digital IDs) where they were.
|
||||
fn migrate(source: &mut std::fs::File, directory: &Path, target: &Path) -> std::io::Result<()> {
|
||||
create_private_directory(directory)?;
|
||||
for _ in 0..32 {
|
||||
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let staging = directory.join(format!(".app.ron-migrate-{}-{id}", std::process::id()));
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = match options.open(&staging) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let result = (|| {
|
||||
std::io::copy(source, &mut file)?;
|
||||
file.sync_all()?;
|
||||
// Both files are in the same directory/filesystem. Unlike rename, hard_link
|
||||
// atomically refuses an existing destination on Unix as well as Windows.
|
||||
match std::fs::hard_link(&staging, target) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
})();
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&staging);
|
||||
return result;
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free settings migration staging name"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xdg_preferences_use_config_instead_of_data() {
|
||||
let fixture = Fixture::new();
|
||||
let home = fixture.0.join("home");
|
||||
let data = home.join(".local/share/pdfcraft");
|
||||
assert_eq!(directory(true, None, Some(data), None, Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
|
||||
}
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-settings-{}-{id}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_override_and_invalid_overrides() {
|
||||
let f = Fixture::new();
|
||||
let home = f.0.join("home");
|
||||
let config = f.0.join("custom-config");
|
||||
assert_eq!(directory(true, None, None, Some(config.clone()), Some(home.clone())).unwrap(), Some(config.join("pdfcraft")));
|
||||
for invalid in [PathBuf::new(), PathBuf::from("relative")] {
|
||||
assert_eq!(directory(true, None, None, Some(invalid), Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
|
||||
}
|
||||
assert_eq!(directory(true, None, Some(f.0.clone()), None, None).unwrap(), Some(f.0.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_preserves_preferences_and_other_data() {
|
||||
let f = Fixture::new();
|
||||
let data = f.0.join("data/pdfcraft");
|
||||
let config = f.0.join("config");
|
||||
std::fs::create_dir_all(data.join("recovery")).unwrap();
|
||||
std::fs::write(data.join("app.ron"), b"(theme:Dark,language:German)").unwrap();
|
||||
std::fs::write(data.join("recovery/unsaved.pdf"), b"UNSAVED").unwrap();
|
||||
std::fs::write(data.join("digital-id.p12"), b"KEY").unwrap();
|
||||
let selected = directory(true, None, Some(data.clone()), Some(config.clone()), None).unwrap().unwrap();
|
||||
assert_eq!(selected, config.join("pdfcraft"));
|
||||
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), std::fs::read(data.join("app.ron")).unwrap());
|
||||
assert_eq!(std::fs::read(data.join("recovery/unsaved.pdf")).unwrap(), b"UNSAVED");
|
||||
assert_eq!(std::fs::read(data.join("digital-id.p12")).unwrap(), b"KEY");
|
||||
// The migrated file is a copy, not a link to the original settings.
|
||||
std::fs::write(selected.join("app.ron"), b"NEW").unwrap();
|
||||
assert_eq!(std::fs::read(data.join("app.ron")).unwrap(), b"(theme:Dark,language:German)");
|
||||
assert_eq!(directory(true, None, Some(data), Some(config), None).unwrap(), Some(selected.clone()));
|
||||
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), b"NEW");
|
||||
assert_eq!(std::fs::read_dir(selected).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_or_concurrently_created_preferences_are_never_replaced() {
|
||||
let f = Fixture::new();
|
||||
let source = f.0.join("old.ron");
|
||||
let config = f.0.join("config/pdfcraft");
|
||||
std::fs::write(&source, b"OLD").unwrap();
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
let target = config.join("app.ron");
|
||||
std::fs::write(&target, b"CURRENT").unwrap();
|
||||
migrate(&mut std::fs::File::open(source).unwrap(), &config, &target).unwrap();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"CURRENT");
|
||||
assert_eq!(std::fs::read_dir(config).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_migration_keeps_original_available() {
|
||||
let f = Fixture::new();
|
||||
std::fs::write(f.0.join("app.ron"), b"OLD").unwrap();
|
||||
let blocked = f.0.join("blocked");
|
||||
std::fs::write(&blocked, b"NOT A DIRECTORY").unwrap();
|
||||
assert!(directory(true, None, Some(f.0.clone()), Some(blocked), None).is_err());
|
||||
assert_eq!(std::fs::read(f.0.join("app.ron")).unwrap(), b"OLD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_and_non_xdg_platforms_keep_their_locations() {
|
||||
let f = Fixture::new();
|
||||
let portable = f.0.join("PdfCraftData");
|
||||
for xdg in [true, false] {
|
||||
assert_eq!(directory(xdg, Some(&portable), Some(f.0.clone()), Some(f.0.clone()), None).unwrap(), Some(portable.clone()));
|
||||
}
|
||||
assert_eq!(directory(false, None, Some(f.0.clone()), Some(portable), None).unwrap(), Some(f.0.clone()));
|
||||
assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 0, "no migration outside XDG mode");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_config_directory_is_private_and_existing_permissions_are_preserved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Fixture::new();
|
||||
let selected = directory(true, None, None, Some(f.0.join("config")), None).unwrap().unwrap();
|
||||
assert_eq!(std::fs::metadata(&selected).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o750)).unwrap();
|
||||
create_private_directory(&selected).unwrap();
|
||||
assert_eq!(std::fs::metadata(selected).unwrap().permissions().mode() & 0o777, 0o750);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,50 @@
|
||||
//! One PdfCraft per Windows sign-in: a launch that only names files hands them to the PdfCraft
|
||||
//! that is already running, where they open as tabs, and exits (#282, #317).
|
||||
//! One PdfCraft per Windows sign-in or Linux/BSD display: a launch that only names files hands them
|
||||
//! to the PdfCraft that is already running, where they open as tabs, and exits (#282, #317, #1726).
|
||||
//!
|
||||
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, so
|
||||
//! until now every file got its own process and window. The first PdfCraft listens on a Unix
|
||||
//! domain socket (Windows 10 1803 and later) in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of
|
||||
//! a portable copy. The socket is named after the Windows session (`instance-Console.sock`), so
|
||||
//! two sign-ins of the same user never open files in each other's windows, and it lives in the
|
||||
//! user's own profile, so other accounts can't create, replace or reach it: nobody else can
|
||||
//! intercept a launch or open files in this user's window. The exchange is one line each way, a
|
||||
//! JSON request naming absolute paths and `ok`, and the running app opens the paths exactly as
|
||||
//! File ▸ Open would.
|
||||
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, and
|
||||
//! a file manager or `xdg-open` starts `pdfcraft <file>` the same way, so until now every file got
|
||||
//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10
|
||||
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy,
|
||||
//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same
|
||||
//! user never open files in each other's windows. On Linux and the BSDs it is in
|
||||
//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a
|
||||
//! launch on another display or seat opens its own window there. Either way it lives in a folder
|
||||
//! only the user can reach, so other accounts can't create, replace or reach it: nobody else can
|
||||
//! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is
|
||||
//! the app's own and is shared by every run of it, so the handoff works there too. The exchange is
|
||||
//! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the
|
||||
//! paths exactly as File ▸ Open would.
|
||||
//!
|
||||
//! Every failure falls back to the old behaviour: a launch that can't hand its files over within
|
||||
//! a few seconds opens its own window. `--new-window`, any other option, or no files at all also
|
||||
//! starts a window of its own, as before. Other platforms keep their current behaviour (macOS
|
||||
//! already routes Finder opens to the running app through Apple events).
|
||||
//! starts a window of its own, as before. macOS keeps its current behaviour (it already routes
|
||||
//! Finder opens to the running app through Apple events).
|
||||
//!
|
||||
//! The socket comes from `uds_windows`, already in the lockfile through zbus: `std` has no Unix
|
||||
//! sockets on Windows, and calling Winsock directly would need `unsafe`, which the workspace
|
||||
//! forbids.
|
||||
//! On Windows the socket comes from `uds_windows`, already in the lockfile through zbus: `std` has
|
||||
//! no Unix sockets on Windows, and calling Winsock directly would need `unsafe`, which the
|
||||
//! workspace forbids.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
#[cfg(windows)]
|
||||
use uds_windows::{UnixListener, UnixStream};
|
||||
|
||||
use pdfcraft_ui_egui::{OsEvent, OsEventsFn};
|
||||
|
||||
/// Longest request read: one line of JSON, far longer than any real list of paths.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
const MAX_REQUEST: u64 = 1 << 20;
|
||||
|
||||
/// How long either side waits for the other before giving up.
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// What this launch should do.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
pub enum Claim {
|
||||
/// The running PdfCraft took the files: exit without a window.
|
||||
HandedOff,
|
||||
@@ -59,13 +68,13 @@ pub struct Server {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
fn new() -> Self {
|
||||
Self { inbox: Arc::default() }
|
||||
}
|
||||
|
||||
/// Queue files from another launch and wake the UI.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
fn deliver(&self, paths: Vec<String>) {
|
||||
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
inbox.paths.extend(paths);
|
||||
@@ -76,8 +85,10 @@ impl Server {
|
||||
|
||||
/// The queue the UI drains every frame (`PdfCraftApp::os_events`). Files that arrived before
|
||||
/// the window existed are in the first batch. A batch also brings the window forward, as the
|
||||
/// new window it replaces would have been.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
|
||||
/// new window it replaces would have been. A Wayland window can't bring itself forward (winit
|
||||
/// ignores `Focus` there), so on `native_wayland` it asks for the user's attention instead,
|
||||
/// which desktops show by highlighting it in the taskbar.
|
||||
pub fn connect(&self, ctx: &egui::Context, native_wayland: bool) -> OsEventsFn {
|
||||
let wake = ctx.clone();
|
||||
self.inbox.lock().unwrap_or_else(PoisonError::into_inner).wake = Some(Box::new(move || wake.request_repaint()));
|
||||
let inbox = Arc::clone(&self.inbox);
|
||||
@@ -89,13 +100,16 @@ impl Server {
|
||||
}
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
if native_wayland {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
|
||||
}
|
||||
vec![OsEvent::Open(paths)]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The request a launch sends: `{"open":[…]}` and a newline.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
pub fn request(paths: &[String]) -> String {
|
||||
let mut line = serde_json::json!({ "open": paths }).to_string();
|
||||
line.push('\n');
|
||||
@@ -104,7 +118,7 @@ pub fn request(paths: &[String]) -> String {
|
||||
|
||||
/// The paths a request names, or `None` when it isn't a request: anything but one JSON object
|
||||
/// whose `open` is a non-empty list of non-empty strings.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
pub fn parse_request(line: &str) -> Option<Vec<String>> {
|
||||
let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
|
||||
let open = value.get("open")?.as_array()?;
|
||||
@@ -121,14 +135,14 @@ pub fn absolute(paths: &[String]) -> Vec<String> {
|
||||
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
|
||||
/// when the launch asked for anything besides opening files, so it gets its own window.
|
||||
pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
{
|
||||
match socket_path() {
|
||||
Some(path) => claim_at(&path, files, may_hand_off),
|
||||
None => Claim::Alone,
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
|
||||
{
|
||||
let _ = (files, may_hand_off);
|
||||
Claim::Alone
|
||||
@@ -148,6 +162,28 @@ fn socket_path() -> Option<std::path::PathBuf> {
|
||||
std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name))
|
||||
}
|
||||
|
||||
/// The socket in `$XDG_RUNTIME_DIR/pdfcraft`; none when `$XDG_RUNTIME_DIR` is unset. Never `/tmp`
|
||||
/// instead: other accounts can create files there, and so could listen in this user's place.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn socket_path() -> Option<std::path::PathBuf> {
|
||||
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let name = display_socket_name(var("WAYLAND_DISPLAY").or_else(|| var("DISPLAY")).as_deref());
|
||||
// The XDG base directory spec makes it the user's own (mode 0700), so the folder inside it is
|
||||
// out of other accounts' reach whatever the umask.
|
||||
var("XDG_RUNTIME_DIR").map(|d| std::path::PathBuf::from(d).join("pdfcraft").join(name))
|
||||
}
|
||||
|
||||
/// The socket's file name for a display (`WAYLAND_DISPLAY`, else `DISPLAY`): a launch opens its
|
||||
/// files on the display it was started on, never in a window on another seat, a nested
|
||||
/// compositor or a remote X display. Letters, digits, `-`, `_` and `.` are kept, and of those the
|
||||
/// last 32, where an absolute `WAYLAND_DISPLAY` (`/run/user/1000/wayland-1`) differs.
|
||||
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||
fn display_socket_name(display: Option<&str>) -> String {
|
||||
let kept: Vec<char> = display.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect();
|
||||
let display: String = kept.get(kept.len().saturating_sub(32)..).unwrap_or_default().iter().collect();
|
||||
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.
|
||||
@@ -161,9 +197,9 @@ fn socket_name(session: Option<&str>) -> String {
|
||||
/// files selected in Explorer and opened at once) take turns: the first one is listening before
|
||||
/// the next one looks. Without it a launch could find the socket bound but not yet listening,
|
||||
/// fail to clear it ("Access is denied") and open a window of its own. The lock is on a file
|
||||
/// beside the socket, which is never deleted; Windows releases it when the process ends, however
|
||||
/// it ends. Best effort: without the lock the launch goes on as before.
|
||||
#[cfg(windows)]
|
||||
/// beside the socket, which is never deleted; the system releases it when the process ends,
|
||||
/// however it ends. Best effort: without the lock the launch goes on as before.
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn election(socket: &std::path::Path) -> Option<std::fs::File> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
@@ -183,9 +219,8 @@ fn election(socket: &std::path::Path) -> Option<std::fs::File> {
|
||||
}
|
||||
|
||||
/// [`claim`] with the socket at `path`.
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim {
|
||||
use uds_windows::{UnixListener, UnixStream};
|
||||
// Released when this function returns: by then this launch listens, or has handed over.
|
||||
let _election = election(path);
|
||||
// Two launches can start together: the one that loses the race to bind hands over to the
|
||||
@@ -241,8 +276,8 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
|
||||
}
|
||||
|
||||
/// Send `files` over `stream` and wait for the running app's `ok`.
|
||||
#[cfg(windows)]
|
||||
fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Result<()> {
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> {
|
||||
use std::io::{BufRead, Read, Write};
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
@@ -254,8 +289,8 @@ fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Resu
|
||||
}
|
||||
|
||||
/// Accept launches on a background thread for as long as the process runs.
|
||||
#[cfg(windows)]
|
||||
fn serve(listener: uds_windows::UnixListener) -> Claim {
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn serve(listener: UnixListener) -> Claim {
|
||||
let server = Server::new();
|
||||
let inbox = Server { inbox: Arc::clone(&server.inbox) };
|
||||
let started = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
|
||||
@@ -278,8 +313,8 @@ fn serve(listener: uds_windows::UnixListener) -> Claim {
|
||||
}
|
||||
|
||||
/// Read one launch's request, queue its files and confirm.
|
||||
#[cfg(windows)]
|
||||
fn receive(stream: &uds_windows::UnixStream, server: &Server) -> std::io::Result<()> {
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> {
|
||||
use std::io::{BufRead, Read, Write};
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
@@ -338,26 +373,40 @@ mod tests {
|
||||
// Before the window exists: queued, nothing to wake yet.
|
||||
server.deliver(vec!["a.pdf".into()]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut poll = server.connect(&ctx);
|
||||
let mut poll = server.connect(&ctx, false);
|
||||
server.deliver(vec!["b.pdf".into()]);
|
||||
let events = poll();
|
||||
assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"]));
|
||||
assert!(poll().is_empty(), "each file is delivered once");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn each_display_gets_its_own_socket_name() {
|
||||
assert_eq!(display_socket_name(Some("wayland-0")), "instance-wayland-0.sock");
|
||||
assert_eq!(display_socket_name(Some(":0")), "instance-0.sock");
|
||||
assert_ne!(display_socket_name(Some(":1.0")), display_socket_name(Some(":10")));
|
||||
assert_ne!(display_socket_name(Some("wayland-0")), display_socket_name(Some("wayland-1")));
|
||||
// An absolute WAYLAND_DISPLAY keeps the end of its path, where nested compositors differ.
|
||||
let nested = display_socket_name(Some("/home/someone/.cache/nested-compositor/run/wayland-1"));
|
||||
assert!(nested.ends_with("wayland-1.sock") && !nested.contains('/'), "{nested}");
|
||||
assert_eq!(display_socket_name(Some("../../x/y")), "instance-....xy.sock", "never a path");
|
||||
assert_eq!(display_socket_name(None), "instance.sock");
|
||||
assert_eq!(display_socket_name(Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn a_second_launch_hands_its_files_to_the_first() {
|
||||
let path = socket("hand-off");
|
||||
let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") };
|
||||
let mut poll = first.connect(&egui::Context::default());
|
||||
let mut poll = first.connect(&egui::Context::default(), false);
|
||||
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
|
||||
assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
|
||||
// A launch with options keeps its own window and doesn't take over the socket.
|
||||
@@ -365,10 +414,10 @@ mod tests {
|
||||
assert!(poll().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn launches_started_together_share_one_window() {
|
||||
// Several files selected in Explorer and opened with Enter: one process per file, all at once.
|
||||
// Several files selected in Explorer or a file manager and opened at once: one process per file.
|
||||
let path = socket("together");
|
||||
let barrier = Arc::new(std::sync::Barrier::new(8));
|
||||
let launches: Vec<_> = (0..8)
|
||||
@@ -386,12 +435,12 @@ mod tests {
|
||||
assert_eq!(claims.iter().filter(|c| matches!(c, Claim::HandedOff)).count(), 7, "the rest hand over");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn a_socket_left_by_a_crashed_instance_is_replaced() {
|
||||
let path = socket("stale");
|
||||
// Bind and drop: the file stays, nobody listens.
|
||||
drop(uds_windows::UnixListener::bind({
|
||||
drop(UnixListener::bind({
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
&path
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! vendor/winit patch (12) (vendor/README.md).
|
||||
|
||||
#[test]
|
||||
fn winit_lets_macos_title_bar_drags_reach_the_app() {
|
||||
// On macOS the tab strip is the title bar: a transparent title bar over a full-size content
|
||||
// view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit
|
||||
// moved the whole window for a drag that began on a document tab, and tabs could not be
|
||||
// reordered. vendor/winit answers with the window's `movable_by_window_background` (NO unless
|
||||
// asked); the empty strip still moves the window through `ViewportCommand::StartDrag`. A
|
||||
// re-vendored copy without the patch would silently bring the bug back: re-apply the patch, or
|
||||
// drop the copy once winit has the fix.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap();
|
||||
let (_, method) = view.split_once("#[method(mouseDownCanMoveWindow)]").expect("a title-bar drag moves the window again");
|
||||
let body = method.split_once("\n }\n").map_or(method, |(body, _)| body);
|
||||
assert!(body.contains("movable_by_window_background"), "mouseDownCanMoveWindow no longer follows the window's setting");
|
||||
// The strip still moves the window itself, from its empty background.
|
||||
let chrome = std::fs::read_to_string(root.join("crates/ui-egui/src/chrome.rs")).unwrap();
|
||||
assert!(chrome.contains("ViewportCommand::StartDrag"), "the empty tab strip no longer moves the window");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
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"]
|
||||
mod scroll;
|
||||
|
||||
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
|
||||
match phase {
|
||||
event::TouchPhase::Started => TouchPhase::Start,
|
||||
event::TouchPhase::Moved => TouchPhase::Move,
|
||||
event::TouchPhase::Ended => TouchPhase::End,
|
||||
event::TouchPhase::Cancelled => TouchPhase::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
let ctx = egui::Context::default();
|
||||
let mut previous = event::TouchPhase::Ended;
|
||||
let mut seen = Vec::new();
|
||||
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
for (frame, modifiers) in [ctrl, ctrl, Modifiers::NONE].into_iter().enumerate() {
|
||||
let phase = scroll::phase(previous, Some(source), discrete, false);
|
||||
previous = phase;
|
||||
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
|
||||
let input = RawInput {
|
||||
time: Some(frame as f64 / 60.0),
|
||||
events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
|
||||
..Default::default()
|
||||
};
|
||||
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
|
||||
let seen = wheel_trace(source, false);
|
||||
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
|
||||
assert_eq!(seen[2].0, 1.0, "releasing Ctrl must stop zooming: {source:?}");
|
||||
assert!(seen[2].1.y > 0.0, "the next wheel event must scroll: {source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discrete_wheels_still_follow_current_modifiers() {
|
||||
let seen = wheel_trace(AxisSource::Wheel, true);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
assert_eq!(seen[2].0, 1.0);
|
||||
assert!(seen[2].1.y > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
// A finger scroll's momentum may arrive after Ctrl is released.
|
||||
let seen = wheel_trace(AxisSource::Finger, false);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
assert!(seen[2].0 > 1.0);
|
||||
assert_eq!(seen[2].1, egui::Vec2::ZERO);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Moved, Some(AxisSource::Finger), false, true), event::TouchPhase::Ended);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_sources_keep_the_existing_phase_policy() {
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Moved, None, false, true), event::TouchPhase::Ended);
|
||||
}
|
||||
@@ -25,6 +25,11 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.edurbs]
|
||||
real_name = "Eduardo Soares"
|
||||
display_name = "Eduardo Soares"
|
||||
sync_github_name = false
|
||||
|
||||
[people.jcl-bit]
|
||||
real_name = "James Locke"
|
||||
display_name = "James Locke"
|
||||
@@ -129,3 +134,17 @@ sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.mtlcl410]
|
||||
display_name = "mtlcl410"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
+199
-27
@@ -27,6 +27,31 @@ pub enum AltError {
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
/// Why a structure tag cannot safely become an artifact.
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum ArtifactError {
|
||||
#[error("the document has no tags")]
|
||||
Untagged,
|
||||
#[error("object {0} is not a reachable indirect structure tag")]
|
||||
NotATag(u32),
|
||||
#[error("the tag has child structure elements; mark a leaf tag instead")]
|
||||
HasChildren,
|
||||
#[error("the tag has no direct marked content")]
|
||||
NoMarkedContent,
|
||||
#[error("the tag has no page")]
|
||||
NoPage,
|
||||
#[error("the tag's marked content was not found on its page")]
|
||||
ContentNotFound,
|
||||
#[error("the tag has no removable structure-tree parent")]
|
||||
NoParent,
|
||||
#[error("the parent must list the tag exactly once")]
|
||||
AmbiguousParent,
|
||||
#[error("couldn't update the parent tree: {0}")]
|
||||
ParentTree(String),
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
pub(crate) fn tree_root(doc: &Document) -> Option<Dict> {
|
||||
let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned())?;
|
||||
cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned())
|
||||
@@ -115,6 +140,22 @@ fn mcids(d: &Dict) -> Vec<i64> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The page's named property lists (`/Resources /Properties`), which a `BDC` can name instead of
|
||||
/// writing its property list inline.
|
||||
fn page_properties(doc: &Document, page: &Dict) -> Dict {
|
||||
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
res.get(b"Properties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The marked-content id a `BDC` opens, whether its property list is inline or named in `props`.
|
||||
fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option<i64> {
|
||||
match op.operands.get(1)? {
|
||||
Object::Dict(d) => d.get(b"MCID").and_then(Object::as_int),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
@@ -145,6 +186,7 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
|
||||
fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[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);
|
||||
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;
|
||||
@@ -165,9 +207,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
|
||||
}
|
||||
}
|
||||
b"BMC" => marks.push(false),
|
||||
b"BDC" => marks.push(
|
||||
matches!(op.operands.get(1), Some(Object::Dict(d)) if d.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))),
|
||||
),
|
||||
b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))),
|
||||
b"EMC" => {
|
||||
marks.pop();
|
||||
}
|
||||
@@ -262,6 +302,72 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page's content entries: one stream, or the elements of a contents array.
|
||||
fn content_entries(doc: &Document, page: &Dict) -> Option<Vec<Object>> {
|
||||
let c = page.get(b"Contents")?;
|
||||
Some(match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
Object::Stream(_) => vec![c.clone()],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet<i64>) -> bool {
|
||||
op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n))
|
||||
}
|
||||
|
||||
/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that
|
||||
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
|
||||
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 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 {
|
||||
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 {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let mut dict = stream.dict;
|
||||
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);
|
||||
}
|
||||
if changed {
|
||||
let contents = match next.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => Object::Array(next),
|
||||
};
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Decorative figure: its marked content on the page becomes an artifact, and the element
|
||||
/// leaves the structure tree (and the parent tree).
|
||||
pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> {
|
||||
@@ -269,24 +375,12 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
|
||||
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
|
||||
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
|
||||
let mut changed = false;
|
||||
for op in ops.iter_mut() {
|
||||
let ours = op.op == b"BDC"
|
||||
&& matches!(op.operands.get(1), Some(Object::Dict(m)) if m.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n)));
|
||||
if ours {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops))));
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(s)))?;
|
||||
// The parent tree no longer points those ids at the figure.
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj)?;
|
||||
}
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty())
|
||||
&& artifact_marked_content(doc, page, &ids)?
|
||||
{
|
||||
// The parent tree no longer points those ids at the figure.
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj)?;
|
||||
}
|
||||
}
|
||||
// Out of its parent's kids.
|
||||
@@ -305,17 +399,95 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a leaf structure tag's directly owned marked content as an artifact.
|
||||
///
|
||||
/// This deliberately refuses a tag with child structure elements. Converting a parent would
|
||||
/// otherwise silently discard semantic descendants. The visible page content is preserved: each
|
||||
/// matching marked-content sequence changes from `BDC` with an MCID to `/Artifact BMC`.
|
||||
pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), ArtifactError> {
|
||||
tree_root(doc).ok_or(ArtifactError::Untagged)?;
|
||||
let elements = elements(doc);
|
||||
let Some(element) = elements.iter().find(|e| e.object == obj) else { return Err(ArtifactError::NotATag(obj.num)) };
|
||||
if has_child_structure(doc, &element.dict) {
|
||||
return Err(ArtifactError::HasChildren);
|
||||
}
|
||||
let ids: HashSet<i64> = mcids(&element.dict).into_iter().collect();
|
||||
if ids.is_empty() {
|
||||
return Err(ArtifactError::NoMarkedContent);
|
||||
}
|
||||
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 found = HashSet::new();
|
||||
for op in &mut ops {
|
||||
let id = (op.op == b"BDC")
|
||||
.then(|| match op.operands.get(1) {
|
||||
Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|id| ids.contains(id));
|
||||
if let Some(id) = id {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
found.insert(id);
|
||||
}
|
||||
}
|
||||
if found != ids {
|
||||
return Err(ArtifactError::ContentNotFound);
|
||||
}
|
||||
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops))));
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?;
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
|
||||
}
|
||||
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
|
||||
remove_from_parent(doc, parent, obj)?;
|
||||
// Keep the retired object valid if another object holds an out-of-tree reference to it.
|
||||
doc.update_dict(obj, |d| {
|
||||
d.remove(b"P");
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_child_structure(doc: &Document, tag: &Dict) -> bool {
|
||||
fn child(doc: &Document, item: &Object) -> bool {
|
||||
match item {
|
||||
Object::Array(items) => items.iter().any(|item| child(doc, item)),
|
||||
Object::Ref(r) => doc.get(*r).as_dict().is_some_and(|d| d.contains(b"S")),
|
||||
Object::Dict(d) => d.contains(b"S"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
tag.get(b"K").is_some_and(|items| child(doc, items))
|
||||
}
|
||||
|
||||
fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> {
|
||||
let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?;
|
||||
let replacement = match kids {
|
||||
Object::Array(items) => {
|
||||
if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 {
|
||||
return Err(ArtifactError::AmbiguousParent);
|
||||
}
|
||||
Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect())
|
||||
}
|
||||
Object::Ref(r) if r == obj => Object::Array(Vec::new()),
|
||||
_ => return Err(ArtifactError::NoParent),
|
||||
};
|
||||
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree.
|
||||
fn clear_parent_tree(doc: &mut Document, key: i64, obj: ObjRef) -> Result<(), AltError> {
|
||||
let Some(pt) = tree_root(doc).and_then(|r| r.get(b"ParentTree").and_then(Object::as_ref)) else { return Ok(()) };
|
||||
let Some(nums) = doc.get(pt).as_dict().and_then(|d| d.get(b"Nums").and_then(|n| n.as_array().cloned())) else { return Ok(()) };
|
||||
let mut nums = nums;
|
||||
for pair in nums.chunks_mut(2) {
|
||||
if pair[0].as_int() == Some(key) {
|
||||
match &pair[1] {
|
||||
Object::Array(a) => {
|
||||
pair[1] = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect())
|
||||
}
|
||||
// Whole pairs only: a trailing key without a value maps nothing, so it is left as it is.
|
||||
for [k, v] in nums.as_chunks_mut::<2>().0 {
|
||||
if k.as_int() == Some(key) {
|
||||
match &*v {
|
||||
Object::Array(a) => *v = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect()),
|
||||
Object::Ref(r) => {
|
||||
if let Some(a) = doc.get(*r).as_array().cloned() {
|
||||
let a: Vec<Object> = a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect();
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Conservative tagging for AcroForm widgets.
|
||||
//!
|
||||
//! It creates `/Form` structure elements and `/OBJR` children so assistive technology can find
|
||||
//! widgets. An existing direct `/Document` root and direct `/ParentTree /Nums` are extended;
|
||||
//! unusual tree layouts are refused before any edit is made.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
fn reference(object: Option<&Object>, what: &str) -> Result<ObjRef, String> {
|
||||
object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference"))
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, parent_tree: ObjRef) -> Result<Vec<Object>, String> {
|
||||
let tree = doc.get(parent_tree);
|
||||
let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?;
|
||||
tree.get(b"Nums")
|
||||
.map(|value| doc.resolve(value))
|
||||
.as_deref()
|
||||
.and_then(Object::as_array)
|
||||
.cloned()
|
||||
.ok_or_else(|| "the structure parent tree must use a direct Nums array".to_string())
|
||||
}
|
||||
|
||||
/// Add `/Form` tags for every page widget that does not already have a structure parent.
|
||||
///
|
||||
/// A new structure tree is written for an untagged form. A conventional existing tree is
|
||||
/// extended by adding the form tags to its direct `/Document` element and fresh entries to its
|
||||
/// direct `/ParentTree /Nums` array. Text, figures, links, and non-widget annotations remain
|
||||
/// outside this operation and need separate review.
|
||||
pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
|
||||
let root = doc.root().ok_or("the PDF has no catalog")?;
|
||||
let catalog_object = doc.get(root);
|
||||
let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?;
|
||||
if !catalog.contains(b"AcroForm") {
|
||||
return Err("the PDF has no AcroForm fields".into());
|
||||
}
|
||||
let existing_root = catalog.get(b"StructTreeRoot").cloned();
|
||||
let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default();
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let mut widgets = Vec::new();
|
||||
// A widget listed in more than one page's /Annots is tagged once (one key, one /Form).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for page in &pages {
|
||||
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default();
|
||||
for annot in annots {
|
||||
let Some(reference) = annot.as_ref() else { continue };
|
||||
let resolved = doc.get(reference);
|
||||
let Some(dict) = resolved.as_dict() else { continue };
|
||||
if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) {
|
||||
widgets.push((page.obj, reference));
|
||||
}
|
||||
}
|
||||
}
|
||||
if widgets.is_empty() {
|
||||
return Err("the AcroForm has no untagged page widgets".into());
|
||||
}
|
||||
|
||||
let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root {
|
||||
let struct_root = reference(Some(&existing_root), "structure tree root")?;
|
||||
let structure_object = doc.get(struct_root);
|
||||
let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?;
|
||||
let document_ref = reference(structure.get(b"K"), "structure tree root K")?;
|
||||
let document = doc.get(document_ref);
|
||||
if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) {
|
||||
return Err("the structure tree root must point directly to a Document element".into());
|
||||
}
|
||||
let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?;
|
||||
let parent_nums = nums(doc, parent_tree)?;
|
||||
if parent_nums.len() % 2 != 0 {
|
||||
return Err("the structure parent tree has an odd number of entries".into());
|
||||
}
|
||||
let mut greatest = -1_i64;
|
||||
for pair in parent_nums.as_chunks::<2>().0 {
|
||||
let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) };
|
||||
if key < 0 {
|
||||
return Err("the structure parent tree has a negative key".into());
|
||||
}
|
||||
greatest = greatest.max(key);
|
||||
}
|
||||
let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
|
||||
(struct_root, document_ref, parent_tree, parent_nums, first_key, false)
|
||||
} else {
|
||||
let mut root_dict = Dict::new();
|
||||
root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec()));
|
||||
let struct_root = doc.add(Object::Dict(root_dict));
|
||||
let mut document = Dict::new();
|
||||
document.set(b"S".to_vec(), Object::Name(b"Document".to_vec()));
|
||||
document.set(b"P".to_vec(), Object::Ref(struct_root));
|
||||
let document_ref = doc.add(Object::Dict(document));
|
||||
let parent_tree = doc.add(Object::Dict(Dict::new()));
|
||||
(struct_root, document_ref, parent_tree, Vec::new(), 0, true)
|
||||
};
|
||||
let mut kids = Vec::with_capacity(widgets.len());
|
||||
for (offset, (page, widget)) in widgets.iter().copied().enumerate() {
|
||||
let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?;
|
||||
let mut objr = Dict::new();
|
||||
objr.set(b"Type".to_vec(), Object::Name(b"OBJR".to_vec()));
|
||||
objr.set(b"Obj".to_vec(), Object::Ref(widget));
|
||||
objr.set(b"Pg".to_vec(), Object::Ref(page));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"S".to_vec(), Object::Name(b"Form".to_vec()));
|
||||
form.set(b"P".to_vec(), Object::Ref(document_ref));
|
||||
form.set(b"Pg".to_vec(), Object::Ref(page));
|
||||
form.set(b"K".to_vec(), Object::Dict(objr));
|
||||
let form_ref = doc.add(Object::Dict(form));
|
||||
doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?;
|
||||
parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]);
|
||||
kids.push(Object::Ref(form_ref));
|
||||
}
|
||||
doc.update_dict(document_ref, |d| {
|
||||
let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new()));
|
||||
let mut all = match old {
|
||||
Object::Array(items) => items,
|
||||
item => vec![item],
|
||||
};
|
||||
all.extend(kids);
|
||||
d.set(b"K".to_vec(), Object::Array(all));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?;
|
||||
if new_tree {
|
||||
doc.update_dict(struct_root, |d| {
|
||||
d.set(b"K".to_vec(), Object::Ref(document_ref));
|
||||
d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut mark_info = mark_info;
|
||||
mark_info.set(b"Marked".to_vec(), Object::Bool(true));
|
||||
doc.update_dict(root, |d| {
|
||||
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
|
||||
if new_tree {
|
||||
d.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root));
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(widgets.len())
|
||||
}
|
||||
+130
-25
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_fonts::pdf::Metrics;
|
||||
use pdfcraft_model::Page;
|
||||
@@ -171,13 +172,24 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict {
|
||||
}
|
||||
|
||||
/// The text each marked-content id shows on a page (text drawn directly in the page content).
|
||||
fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
///
|
||||
/// Word breaks come from where the glyphs are, as on the page: a glyph that starts on another
|
||||
/// line, more than 0.2 em past the end of the previous glyph of the same id, or back over more
|
||||
/// than an em from it, starts a new word (the em is the font size times |`Tz`|; see [`new_word`]).
|
||||
/// Text-positioning operators alone don't break words: producers that place every glyph with its
|
||||
/// own `Td` (Chromium) would otherwise read as "C h a p t e r".
|
||||
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();
|
||||
let mut font: Option<Vec<u8>> = None;
|
||||
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.
|
||||
let mut ends: HashMap<i64, (f64, f64)> = HashMap::new();
|
||||
let mut gs = TextState::default();
|
||||
let mut saved: Vec<TextState> = Vec::new();
|
||||
let mut unsaved = 0usize;
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
|
||||
let id = stack.iter().rev().find_map(|m| *m);
|
||||
match op.op.as_slice() {
|
||||
@@ -192,8 +204,22 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()),
|
||||
b"q" => unsaved += 1,
|
||||
b"Q" if unsaved > 0 => unsaved -= 1,
|
||||
b"Q" => {
|
||||
if let Some(saved) = saved.pop() {
|
||||
gs = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
gs.ctm = Matrix(m).then(&gs.ctm);
|
||||
}
|
||||
}
|
||||
b"Tf" => {
|
||||
font = op.name(0).map(<[u8]>::to_vec);
|
||||
gs.size = op.num(1).unwrap_or(gs.size);
|
||||
gs.font = op.name(0).map(<[u8]>::to_vec);
|
||||
if let Some(name) = op.name(0)
|
||||
&& !metrics.contains_key(name)
|
||||
&& let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned())
|
||||
@@ -201,34 +227,113 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let (Some(id), Some(m)) = (id, font.as_ref().and_then(|f| metrics.get(f))) else { continue };
|
||||
let text = out.entry(id).or_default();
|
||||
if text.len() >= MAX_TITLE * 4 {
|
||||
continue;
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"Tj" | b"'" | b"\"" => {
|
||||
if op.op != b"Tj" {
|
||||
text.push(' ');
|
||||
}
|
||||
if let Some(s) = op.operands.iter().rev().find_map(Object::as_string) {
|
||||
text.push_str(&m.decode(&s.bytes));
|
||||
b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0),
|
||||
b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0),
|
||||
b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0,
|
||||
b"TL" => gs.leading = op.num(0).unwrap_or(0.0),
|
||||
b"BT" => (tm, tlm) = (Matrix::IDENTITY, Matrix::IDENTITY),
|
||||
b"Td" | b"TD" => {
|
||||
if let Some([x, y]) = op.nums::<2>() {
|
||||
if op.op == b"TD" {
|
||||
gs.leading = -y;
|
||||
}
|
||||
tlm = Matrix::translate(x, y).then(&tlm);
|
||||
tm = tlm;
|
||||
}
|
||||
}
|
||||
b"TJ" => {
|
||||
for item in op.operands.first().and_then(Object::as_array).into_iter().flatten() {
|
||||
match item {
|
||||
Object::String(s) => text.push_str(&m.decode(&s.bytes)),
|
||||
n if n.as_f64().is_some_and(|n| n < -200.0) => text.push(' '),
|
||||
_ => {}
|
||||
b"Tm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
(tm, tlm) = (Matrix(m), Matrix(m));
|
||||
}
|
||||
}
|
||||
b"T*" => {
|
||||
tlm = Matrix::translate(0.0, -gs.leading).then(&tlm);
|
||||
tm = tlm;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let shows = matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"");
|
||||
let Some(m) = gs.font.as_ref().and_then(|f| metrics.get(f)).filter(|_| shows) else { continue };
|
||||
if op.op != b"Tj" && op.op != b"TJ" {
|
||||
if op.op == b"\"" {
|
||||
gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing);
|
||||
gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing);
|
||||
}
|
||||
tlm = Matrix::translate(0.0, -gs.leading).then(&tlm);
|
||||
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(),
|
||||
_ => op.operands.last().into_iter().collect(),
|
||||
};
|
||||
for piece in pieces {
|
||||
let s = match piece {
|
||||
Object::String(s) => s,
|
||||
other => {
|
||||
// A TJ adjustment moves the next glyph; whether it breaks a word is decided there.
|
||||
let dx = -other.as_f64().unwrap_or(0.0) / 1000.0 * gs.size * gs.scale;
|
||||
tm = Matrix::translate(dx, 0.0).then(&tm);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (code, len) in m.codes(&s.bytes) {
|
||||
let trm = tm.then(&gs.ctm);
|
||||
if let Some(id) = id
|
||||
&& 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 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);
|
||||
if let Some(id) = id {
|
||||
ends.insert(id, tm.then(&gs.ctm).apply(0.0, 0.0));
|
||||
}
|
||||
}
|
||||
b"Td" | b"TD" | b"T*" | b"Tm" => text.push(' '),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How deep `q` nests before the text state stops being saved (a hostile stream can nest millions).
|
||||
const MAX_SAVED: usize = 256;
|
||||
|
||||
/// The graphics state that places glyphs: the CTM, the font and the text state parameters.
|
||||
#[derive(Clone)]
|
||||
struct TextState {
|
||||
ctm: Matrix,
|
||||
font: Option<Vec<u8>>,
|
||||
size: f64,
|
||||
char_spacing: f64,
|
||||
word_spacing: f64,
|
||||
scale: f64,
|
||||
leading: f64,
|
||||
}
|
||||
|
||||
impl Default for TextState {
|
||||
fn default() -> Self {
|
||||
TextState { ctm: Matrix::IDENTITY, font: None, size: 0.0, char_spacing: 0.0, word_spacing: 0.0, scale: 1.0, leading: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a glyph drawn with text rendering matrix `trm` (text space → user space) start a new word
|
||||
/// after a glyph that ended at `end` (user space)? Yes on another line (more than half the absolute
|
||||
/// font size up or down), more than 0.2 horizontal em ahead, or back over more than a horizontal em.
|
||||
/// The horizontal em is the absolute value of font size times horizontal scale (`Tz`); "ahead" follows
|
||||
/// the glyph advance, backwards when the size or `Tz` is negative. `Tz` never scales the vertical threshold.
|
||||
fn new_word(trm: &Matrix, end: (f64, f64), font_size: f64, horizontal_scale: f64) -> bool {
|
||||
let advance = font_size * horizontal_scale;
|
||||
let em = advance.abs();
|
||||
let start = trm.apply(0.0, 0.0);
|
||||
let [a, b, c, d, _, _] = trm.0;
|
||||
let Some(back) = Matrix([a, b, c, d, 0.0, 0.0]).invert() else { return true };
|
||||
let (dx, dy) = back.apply(start.0 - end.0, start.1 - end.1);
|
||||
let ahead = if advance < 0.0 { -dx } else { dx };
|
||||
!(ahead.is_finite() && dy.is_finite()) || dy.abs() > font_size.abs() * 0.5 || ahead > em * 0.2 || ahead < -em
|
||||
}
|
||||
@@ -14,17 +14,19 @@ use pdfcraft_cos::Document;
|
||||
|
||||
mod alt;
|
||||
mod autotag;
|
||||
mod autotag_fields;
|
||||
mod content;
|
||||
mod headings;
|
||||
mod report;
|
||||
mod structure;
|
||||
mod tags;
|
||||
|
||||
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
|
||||
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;
|
||||
pub use headings::{Heading, headings};
|
||||
pub use report::{REPORT_WORDS, report_html};
|
||||
pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags};
|
||||
pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Category {
|
||||
|
||||
@@ -21,6 +21,14 @@ pub struct TagInfo {
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
/// A marked-content reference directly owned by a structure tag.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TagContentItem {
|
||||
pub page: Option<usize>,
|
||||
pub mcid: i64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TagProperty {
|
||||
Type,
|
||||
@@ -140,6 +148,58 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List marked-content references directly owned by an indirect tag.
|
||||
pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>, TagError> {
|
||||
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) };
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
|
||||
let mut marks = Vec::new();
|
||||
if let Some(kids) = element.dict.get(b"K") {
|
||||
direct_marks(doc, kids, element.page, &mut marks);
|
||||
}
|
||||
// Each page's content is parsed once, however many of its MCIDs the tag owns.
|
||||
let mut texts: std::collections::HashMap<usize, std::collections::HashMap<i64, String>> = std::collections::HashMap::new();
|
||||
Ok(marks
|
||||
.into_iter()
|
||||
.map(|(page, mcid)| {
|
||||
let page = page.and_then(|p| page_index.get(&p).copied());
|
||||
let text = page
|
||||
.and_then(|i| {
|
||||
let page_texts = match texts.entry(i) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(pages.get(i).map(|p| crate::headings::mcid_texts(doc, &p.dict)).unwrap_or_default())
|
||||
}
|
||||
};
|
||||
page_texts.get(&mcid).cloned()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
TagContentItem { page, mcid, text }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn direct_marks(doc: &Document, item: &Object, page: Option<ObjRef>, out: &mut Vec<(Option<ObjRef>, i64)>) {
|
||||
match item {
|
||||
Object::Int(mcid) => out.push((page, *mcid)),
|
||||
Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)),
|
||||
Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => {
|
||||
if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) {
|
||||
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
|
||||
}
|
||||
}
|
||||
Object::Ref(reference) => {
|
||||
if let Some(dict) = doc.get(*reference).as_dict()
|
||||
&& dict.name(b"Type") == Some(b"MCR")
|
||||
&& let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int)
|
||||
{
|
||||
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
@@ -96,6 +96,78 @@ fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
|
||||
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_form_fields_writes_widget_structure_and_parent_tree() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /FT /Tx /T (name) /Kids [6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 60 120 80] >>".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedFormFields].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
|
||||
assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed);
|
||||
let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
|
||||
assert!(doc.get(tree).as_dict().unwrap().contains(b"ParentTree"));
|
||||
let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[1].as_ref().unwrap();
|
||||
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(0));
|
||||
let written = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(written)).unwrap();
|
||||
assert_eq!(status(&check(&reopened, &Options { rules: [Rule::TaggedFormFields].into(), pages: None }), Rule::TaggedFormFields), Status::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_form_fields_tags_a_widget_listed_on_two_pages_once() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
|
||||
let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
|
||||
let parent_tree = doc.get(tree).as_dict().unwrap().reference(b"ParentTree").unwrap();
|
||||
let nums = doc.get(parent_tree).as_dict().unwrap().get(b"Nums").and_then(|n| n.as_array().cloned()).unwrap();
|
||||
assert_eq!(nums.len(), 2, "one key and one /Form element for the shared widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_form_fields_extends_a_direct_existing_structure_tree() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
|
||||
"<< /Type /StructTreeRoot /K 7 0 R /ParentTree 8 0 R >>".into(),
|
||||
"<< /S /Document /P 6 0 R /K 9 0 R >>".into(),
|
||||
"<< /Nums [0 [9 0 R]] >>".into(),
|
||||
"<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
|
||||
let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[0].as_ref().unwrap();
|
||||
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(1));
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedFormFields].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed);
|
||||
let document_object = doc.get(pdfcraft_cos::ObjRef::new(7, 0));
|
||||
let document = document_object.as_dict().unwrap();
|
||||
assert_eq!(document.get(b"K").and_then(Object::as_array).unwrap().len(), 2, "the existing paragraph and new form remain siblings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_fails_the_document_rules() {
|
||||
let doc = pdf(
|
||||
@@ -526,6 +598,16 @@ fn figures_are_listed_with_their_alt_text_and_place() {
|
||||
assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_content_lists_direct_marked_content() {
|
||||
let doc = figures_doc();
|
||||
let tags = tags(&doc);
|
||||
let first = tag_content(&doc, tags[1].object).unwrap();
|
||||
let second = tag_content(&doc, tags[2].object).unwrap();
|
||||
assert_eq!((first[0].page, first[0].mcid, first[0].text.as_str()), (Some(0), 0, ""));
|
||||
assert_eq!((second[0].page, second[0].mcid, second[0].text.as_str()), (Some(0), 1, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_tags_move_in_reading_order_without_changing_content() {
|
||||
let mut doc = figures_doc();
|
||||
@@ -591,6 +673,95 @@ fn setting_alt_text_and_marking_decorative() {
|
||||
assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity() {
|
||||
let mut doc = figures_doc();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)).unwrap();
|
||||
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 8]);
|
||||
let c = doc.resolve(doc.get(page).as_dict().unwrap().get(b"Contents").unwrap());
|
||||
let pdfcraft_cos::Object::Stream(c) = &*c else { panic!("one content stream") };
|
||||
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
|
||||
assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}");
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedContent, Rule::FiguresAltText].into(), pages: None });
|
||||
assert_eq!(failed(&report), Vec::<Rule>::new(), "{:?}", report.results);
|
||||
assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(6, 0)), Err(ArtifactError::HasChildren));
|
||||
assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)), Err(ArtifactError::NotATag(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_decorative_survives_a_parent_tree_with_an_unpaired_key() {
|
||||
// A trailing key without a value, matching the page's StructParents (#1809), and the same
|
||||
// with no valid pair at all.
|
||||
let tree = |nums: &str| pdf(&[format!("<< /Nums {nums} >>")], "").get(pdfcraft_cos::ObjRef::new(1, 0)).as_ref().clone();
|
||||
for (nums, cleared) in [("[0 [7 0 R 8 0 R] 0]", "[0 [null 8 0 R] 0]"), ("[0]", "[0]")] {
|
||||
let mut doc = figures_doc();
|
||||
let pt = pdfcraft_cos::ObjRef::new(9, 0);
|
||||
doc.set(pt, tree(nums));
|
||||
let f = figures(&doc);
|
||||
mark_decorative(&mut doc, f[0].obj).unwrap();
|
||||
assert_eq!(figures(&doc).len(), 1, "{nums}");
|
||||
// The pair is cleared and the unpaired key is kept as it was.
|
||||
assert_eq!(*doc.get(pt), tree(cleared), "{nums}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `figures_doc` with its property lists named in the page resources (one inline, one indirect)
|
||||
/// instead of written inline in the content.
|
||||
fn named_figures_doc() -> Document {
|
||||
pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /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 /StructParents 0 \
|
||||
/Resources << /XObject << /Im 10 0 R >> /Properties << /P0 << /MCID 0 >> /P1 11 0 R /Other << /Note (kept) >> >> >> >>"
|
||||
.into(),
|
||||
stream(
|
||||
"/Figure /P0 BDC q 40 0 0 20 10 150 cm /Im Do Q EMC \
|
||||
/Photo /P1 BDC 100 100 50 30 re f EMC",
|
||||
),
|
||||
"<< /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R /RoleMap << /Photo /Figure >> >>".into(),
|
||||
"<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(),
|
||||
"<< /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >>".into(),
|
||||
"<< /S /Photo /P 6 0 R /Pg 3 0 R /Alt (A bar) /K 1 >>".into(),
|
||||
"<< /Nums [0 [7 0 R 8 0 R]] >>".into(),
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream"
|
||||
.into(),
|
||||
"<< /MCID 1 >>".into(),
|
||||
],
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() {
|
||||
let (mut inline, mut named) = (figures_doc(), named_figures_doc());
|
||||
let place = |doc: &Document| figures(doc).into_iter().map(|f| (f.obj, f.page, f.alt, f.bbox)).collect::<Vec<_>>();
|
||||
assert_eq!(place(&named), place(&inline), "both figures, placed where their content is drawn");
|
||||
for doc in [&mut inline, &mut named] {
|
||||
mark_decorative(doc, pdfcraft_cos::ObjRef::new(8, 0)).unwrap();
|
||||
let left = figures(doc);
|
||||
assert_eq!((left.len(), left[0].obj.num, left[0].bbox), (1, 7, Some([10.0, 150.0, 50.0, 170.0])));
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap());
|
||||
let Object::Stream(c) = &*c else { panic!("one content stream") };
|
||||
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
|
||||
assert!(text.contains("/Artifact BMC") && !text.contains("/P1") && !text.contains("/MCID 1"), "{text}");
|
||||
assert!(text.contains("/Im Do"), "the drawing itself stays: {text}");
|
||||
let nums = doc.get(pdfcraft_cos::ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
|
||||
assert_eq!(
|
||||
nums,
|
||||
Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)), Object::Null])])),
|
||||
"the parent tree no longer points MCID 1 at the removed figure"
|
||||
);
|
||||
let r = check(doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(r.result(Rule::TaggedContent).unwrap().status, Status::Passed, "{:?}", r.results);
|
||||
}
|
||||
let props = pdfcraft_model::pages(&named)[0].dict.get(b"Resources").and_then(|r| named.resolve(r).as_dict().cloned());
|
||||
let props = props.and_then(|r| r.get(b"Properties").and_then(|p| named.resolve(p).as_dict().cloned())).unwrap();
|
||||
assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were");
|
||||
}
|
||||
|
||||
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
|
||||
Some(pdfcraft_cos::ObjRef::new(n, 0))
|
||||
}
|
||||
@@ -640,6 +811,114 @@ fn headings_are_found_with_their_levels_titles_and_pages() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_content_reads_the_text_of_each_marked_content_reference() {
|
||||
let doc = pdf(&headings_doc(), "");
|
||||
let items = |n| tag_content(&doc, ref_(n).unwrap()).unwrap().into_iter().map(|i| (i.page, i.mcid, i.text)).collect::<Vec<_>>();
|
||||
assert_eq!(items(12), [(Some(0), 0, "Chapter One".to_string())]);
|
||||
assert_eq!(items(13), [(Some(0), 2, "Sec A".to_string())]);
|
||||
assert_eq!(items(15), [(Some(1), 1, "AB".to_string())]);
|
||||
}
|
||||
|
||||
/// Helvetica advances (standard 14 metrics, per 1000 em) of the characters the tests below draw.
|
||||
fn helvetica_width(c: char) -> f64 {
|
||||
match c {
|
||||
'C' | 'w' => 722.0,
|
||||
'P' => 667.0,
|
||||
'T' | 'F' => 611.0,
|
||||
'v' => 500.0,
|
||||
'r' => 333.0,
|
||||
't' | ' ' => 278.0,
|
||||
'i' => 222.0,
|
||||
_ => 556.0, // a e h o p
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` drawn one glyph at a time from (72, y), each glyph moved into place with its own `Td`,
|
||||
/// as Chromium writes text. With `spaces`, the space is drawn too; without, the next glyph is
|
||||
/// just moved past it.
|
||||
fn glyph_by_glyph(text: &str, size: f64, y: f64, spaces: bool) -> String {
|
||||
let mut ops = format!("BT /F1 {size} Tf 72 {y} Td");
|
||||
let mut step = 0.0;
|
||||
for c in text.chars() {
|
||||
if c == ' ' && !spaces {
|
||||
step += helvetica_width(c) / 1000.0 * size;
|
||||
continue;
|
||||
}
|
||||
ops.push_str(&format!(" {step:.3} 0 Td ({c}) Tj"));
|
||||
step = helvetica_width(c) / 1000.0 * size;
|
||||
}
|
||||
ops + " ET"
|
||||
}
|
||||
|
||||
/// `text` drawn one glyph at a time with `/F1 1 Tf` and a `Tm` per glyph scaled to `size`; the
|
||||
/// space is skipped over, not drawn.
|
||||
fn glyph_by_tm(text: &str, size: f64, y: f64) -> String {
|
||||
let mut ops = "BT /F1 1 Tf".to_string();
|
||||
let mut x = 72.0;
|
||||
for c in text.chars() {
|
||||
if c != ' ' {
|
||||
ops.push_str(&format!(" {size} 0 0 {size} {x:.3} {y} Tm ({c}) Tj"));
|
||||
}
|
||||
x += helvetica_width(c) / 1000.0 * size;
|
||||
}
|
||||
ops + " ET"
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let kids: Vec<String> = (0..contents.len()).map(|i| format!("{} 0 R", 7 + 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 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> >> >>".into(),
|
||||
stream(&marked.join("\n")),
|
||||
format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")),
|
||||
FONT.into(),
|
||||
];
|
||||
objs.extend((0..contents.len()).map(|i| format!("<< /S /H1 /Pg 3 0 R /K {i} >>")));
|
||||
pdf(&objs, "")
|
||||
}
|
||||
|
||||
/// Chromium (Skia) places each glyph with its own `Td`: a heading must read as its words, not as
|
||||
/// "C h a p t e r T w o", whether the space is drawn or only skipped over. A heading set with a
|
||||
/// negative font size or `Tz` keeps its words too.
|
||||
#[test]
|
||||
fn headings_drawn_glyph_by_glyph_keep_their_words() {
|
||||
let doc = heading_page(&[
|
||||
"BT /F1 20 Tf 72 700 Td (Chapter One) Tj ET".into(),
|
||||
glyph_by_glyph("Chapter Two", 20.0, 650.0, true),
|
||||
glyph_by_glyph("Chapter Three", 20.0, 600.0, false),
|
||||
// Each glyph placed with its own Tm, at font size 1 scaled by the matrix.
|
||||
glyph_by_tm("Part Five", 18.0, 550.0),
|
||||
// Kerning inside a word, then a word space as a TJ adjustment, then a wrapped line.
|
||||
"BT /F1 16 Tf 72 450 Td [(Ch) 40 (apter) -350 (Four)] TJ 0 -20 Td (continued) Tj ET".into(),
|
||||
// A negative font size runs the glyphs backwards in text space: alone, in a mirrored
|
||||
// text matrix (upright on the page), with a word space as a TJ adjustment, and the same
|
||||
// with a negative horizontal scale instead (reset after, as Tz outlives the text object).
|
||||
"BT /F1 -20 Tf 72 380 Td (Chapter Six) Tj ET".into(),
|
||||
"BT /F1 -20 Tf -1 0 0 -1 300 330 Tm (Chapter Seven) Tj ET".into(),
|
||||
"BT /F1 -20 Tf 72 280 Td [(Chapter) -300 (Eight)] TJ ET".into(),
|
||||
"BT /F1 20 Tf -100 Tz 300 230 Td [(Chapter) -300 (Nine)] TJ ET 100 Tz".into(),
|
||||
]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(
|
||||
titles,
|
||||
[
|
||||
"Chapter One",
|
||||
"Chapter Two",
|
||||
"Chapter Three",
|
||||
"Part Five",
|
||||
"Chapter Four continued",
|
||||
"Chapter Six",
|
||||
"Chapter Seven",
|
||||
"Chapter Eight",
|
||||
"Chapter Nine"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_has_no_headings() {
|
||||
let mut objs = headings_doc();
|
||||
@@ -736,3 +1015,11 @@ fn a_nested_finding_says_itself() {
|
||||
// Both wordings are translated, each on its own, and the document's words are left alone.
|
||||
assert_eq!(f.said(&|w: &str| format!("«{w}»")), "«Table element on page 3: «rows have between 1 and 2 columns»»");
|
||||
}
|
||||
|
||||
/// Horizontal stretching must not raise the vertical line-break threshold.
|
||||
#[test]
|
||||
fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() {
|
||||
let doc = heading_page(&["BT /F1 20 Tf 400 Tz 72 700 Td (I) Tj 0 -30 Td (Overview) Tj ET".into()]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["I Overview"]);
|
||||
}
|
||||
@@ -269,6 +269,12 @@ fn stamp_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by
|
||||
/// (`/PCTextRotate`, written when it is added to a turned page).
|
||||
fn text_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -666,6 +672,19 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
|
||||
// A callout: the leader line (arrowhead at its first point), and the text box inside
|
||||
// `/Rect` by `/RD`.
|
||||
// Text added to a turned page is drawn upright in its displayed box, which the form
|
||||
// `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes.
|
||||
let turn = if d.contains(b"CL") { 0 } else { text_turn(d) };
|
||||
let (rect, matrix) = if turn == 0 {
|
||||
(rect, None)
|
||||
} 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], Some(pdfcraft_model::view_matrix_for(turn, rect)))
|
||||
};
|
||||
let full = rect;
|
||||
let mut rect = rect;
|
||||
if d.contains(b"CL") {
|
||||
@@ -751,7 +770,11 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"Helv".to_vec(), Object::Dict(font));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
return Some(form(full, &out, res));
|
||||
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()));
|
||||
}
|
||||
return Some(stream);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
+58
-1
@@ -1206,6 +1206,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
border(&mut d);
|
||||
rich_text(&mut d);
|
||||
// The text is laid out for the page as displayed, like a stamp's label.
|
||||
if !matches!(new.shape, Shape::Callout { .. }) {
|
||||
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"PCTextRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1939,6 +1946,47 @@ pub fn set_style(
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the
|
||||
/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new
|
||||
/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt.
|
||||
pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"FreeText") {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into()));
|
||||
}
|
||||
if !size.is_finite() || !(1.0..=400.0).contains(&size) {
|
||||
return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (col, _) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes()));
|
||||
rich_text(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
|
||||
let result = match fitted_box(doc, r, &text) {
|
||||
Some(fit) => {
|
||||
let cur = annot_dict(doc, r);
|
||||
let nums: Vec<f64> =
|
||||
cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||||
// Keep the top and sides; only the bottom edge moves, and only downwards.
|
||||
match four(&nums).map(normalize) {
|
||||
Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false),
|
||||
_ => apply_text_box(doc, r, fit, meta, false),
|
||||
}
|
||||
}
|
||||
None => redraw_or_restore(doc, r, before.clone()),
|
||||
};
|
||||
if result.is_err() {
|
||||
doc.set(r, Object::Dict(before));
|
||||
}
|
||||
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).
|
||||
pub fn fillable(subtype: &str) -> bool {
|
||||
@@ -2173,6 +2221,8 @@ pub struct Props {
|
||||
pub fillable: bool,
|
||||
/// `/IC`, 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>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -2198,6 +2248,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
@@ -2231,6 +2282,11 @@ fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
|
||||
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
|
||||
/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they
|
||||
/// move, list and delete as one. Returns the strikeout's index.
|
||||
///
|
||||
/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
|
||||
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
|
||||
/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
|
||||
/// from the caller, so the edit stays deterministic.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn add_text_replacement(
|
||||
doc: &mut Document,
|
||||
@@ -2241,6 +2297,7 @@ pub fn add_text_replacement(
|
||||
strike: &Style,
|
||||
caret: &Style,
|
||||
meta: &Meta,
|
||||
caret_meta: &Meta,
|
||||
) -> Result<usize, AnnotError> {
|
||||
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
|
||||
let s = add_annotation(
|
||||
@@ -2266,7 +2323,7 @@ pub fn add_text_replacement(
|
||||
let c = add_annotation(
|
||||
doc,
|
||||
&NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() },
|
||||
meta,
|
||||
caret_meta,
|
||||
)?;
|
||||
let (_, cr) = annot_ref(doc, page, c)?;
|
||||
doc.update_dict(cr, |d| {
|
||||
|
||||
@@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) {
|
||||
d.set(b"H".to_vec(), Object::name(s.highlight.code()));
|
||||
}
|
||||
|
||||
/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8).
|
||||
fn uri_bytes(u: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(u.len());
|
||||
for &b in u.as_bytes() {
|
||||
if (0x21..=0x7e).contains(&b) {
|
||||
out.push(b);
|
||||
} else {
|
||||
out.extend_from_slice(format!("%{b:02X}").as_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
|
||||
match a {
|
||||
LinkAction::Page(p) => {
|
||||
@@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno
|
||||
let mut act = Dict::new();
|
||||
act.set(b"S".to_vec(), Object::name("URI"));
|
||||
// URIs are 7-bit ASCII strings (§12.6.4.8).
|
||||
act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec()));
|
||||
act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u)));
|
||||
d.set(b"A".to_vec(), Object::Dict(act));
|
||||
}
|
||||
LinkAction::Other(_) => {}
|
||||
|
||||
@@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() {
|
||||
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_uri_is_percent_encoded_to_7_bit_ascii() {
|
||||
use crate::links::{self, LinkAction, LinkStyle};
|
||||
let mut doc = fixture();
|
||||
for url in ["https://example.com/ação", "https://example.com/a%20b"] {
|
||||
links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap();
|
||||
}
|
||||
let doc = reopen(&doc);
|
||||
let urls: Vec<String> = links::list(&doc)
|
||||
.into_iter()
|
||||
.filter_map(|link| match link.action {
|
||||
LinkAction::Uri(url) => Some(url),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]);
|
||||
assert!(urls.iter().all(|url| url.is_ascii()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() {
|
||||
use crate::links::{self, LinkAction, LinkStyle};
|
||||
@@ -784,7 +803,7 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
|
||||
let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap();
|
||||
let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let a = list(&doc, 0);
|
||||
let strike = &a[i];
|
||||
@@ -792,13 +811,21 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
|
||||
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..])));
|
||||
assert!(caret.reference(b"IRT").is_some());
|
||||
// #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it
|
||||
// replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the
|
||||
// pair does not survive an export and import.
|
||||
assert_eq!(text(caret, b"NM").as_str(), "r-caret");
|
||||
assert_eq!(text(strike, b"NM").as_str(), "r");
|
||||
assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name");
|
||||
let irt = caret.reference(b"IRT").unwrap();
|
||||
assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout");
|
||||
let r = rect(caret);
|
||||
assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}");
|
||||
// Deleting the strikeout takes the caret with it (the group).
|
||||
let mut doc = doc;
|
||||
delete_annotation(&mut doc, 0, i).unwrap();
|
||||
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
|
||||
assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err());
|
||||
assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1273,3 +1300,38 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() {
|
||||
assert!(!a.is_empty());
|
||||
assert_eq!(a, b, "the reused appearance is byte-identical");
|
||||
}
|
||||
|
||||
/// #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]
|
||||
fn text_box_font_size_changes_the_da_and_grows_the_box() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap();
|
||||
let height = |doc: &Document| {
|
||||
let rect: Vec<f64> = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
rect[3] - rect[1]
|
||||
};
|
||||
let before = height(&doc);
|
||||
set_font_size(&mut doc, 0, t, 24.0, &meta("")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!(text(d, b"DA"), "1 0 0 rg /Helv 24 Tf");
|
||||
assert!(text(d, b"DS").contains("24pt") && text(d, b"RC").contains("font-size:24pt"));
|
||||
assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(24.0));
|
||||
assert!(height(&doc) > before, "the box grows");
|
||||
let mut doc = doc;
|
||||
let grown = height(&doc);
|
||||
set_font_size(&mut doc, 0, t, 6.0, &meta("")).unwrap();
|
||||
assert!(height(&doc) >= grown, "the box never shrinks");
|
||||
for bad in [0.0, 0.5, 401.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(matches!(set_font_size(&mut doc, 0, t, bad, &meta("")), Err(AnnotError::Invalid(_))), "{bad}");
|
||||
}
|
||||
assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(6.0));
|
||||
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [50.0, 50.0], icon: NoteIcon::Note }), &meta("t"));
|
||||
if let Ok(i) = note {
|
||||
assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err());
|
||||
assert_eq!(props(&doc, 0, i).unwrap().font_size, None);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -16,6 +16,11 @@ It reports two kinds of finding:
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
shading and highlights don't either: they're painted before the text, or light against it.
|
||||
|
||||
A rectangle covers a text line when it hides at least half of one of the line's words, at
|
||||
least 40% of the area of the words the line draws, or at least 40% of the line's box together
|
||||
with a positive-area intersection with one of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
|
||||
holds no word, so a redaction box over it alone does not count.
|
||||
|
||||
Limits, by design and documented rather than hidden:
|
||||
|
||||
- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose
|
||||
@@ -23,9 +28,13 @@ Limits, by design and documented rather than hidden:
|
||||
translucent one (`/ca` below 1) is not flagged;
|
||||
- covers inside form XObjects are found, but only text drawn directly in the page content is
|
||||
checked under them;
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign.
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
|
||||
- a cover that hides less than half of every word it touches, less than 40% of a line's box and
|
||||
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".
|
||||
|
||||
Surfaces: the `redact_audit` automation tool and `pdfcraft-cli`.
|
||||
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
|
||||
the other tools; `AuditFinding::rect` itself stays in PDF user space.
|
||||
@@ -219,10 +219,13 @@ impl<'a> Walker<'a> {
|
||||
b"h" => {
|
||||
path = match path {
|
||||
Path::Lines(pts, _) => Path::Lines(pts, true),
|
||||
// `re` already closed its subpath, so a further `h` changes nothing.
|
||||
Path::Rect(r) => Path::Rect(r),
|
||||
_ => Path::Other,
|
||||
};
|
||||
}
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
|
||||
// `b`/`b*` close the path before filling it, which the fill does anyway.
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
||||
if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect()
|
||||
&& gs.alpha >= 1.0
|
||||
{
|
||||
|
||||
+54
-24
@@ -9,12 +9,13 @@
|
||||
//! unreadable (WCAG contrast below 3.0). Page backgrounds, table shading and highlight
|
||||
//! marks are therefore not flagged.
|
||||
//!
|
||||
//! Detection limits (documented, not hidden): only `re` rectangles closed by a fill operator
|
||||
//! are considered — arbitrary 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; 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.
|
||||
//! 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;
|
||||
//! 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.
|
||||
//!
|
||||
//! The audit fails closed: any content that cannot be read (an undecodable content stream,
|
||||
//! malformed annotations, form XObjects nested deeper than 32 or in a cycle) is an error, never
|
||||
@@ -40,6 +41,11 @@ pub enum FindingKind {
|
||||
}
|
||||
|
||||
impl FindingKind {
|
||||
/// The stable machine-readable ID for this finding class.
|
||||
///
|
||||
/// These strings are the crate's public vocabulary: they appear in CLI output and are
|
||||
/// the IDs a future machine-readable format would serialize. Do not rename them;
|
||||
/// add a new variant instead.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FindingKind::CoveredText => "covered-text",
|
||||
@@ -54,9 +60,11 @@ pub struct AuditFinding {
|
||||
/// 0-based page index.
|
||||
pub page: usize,
|
||||
pub kind: FindingKind,
|
||||
/// The covering rectangle (or the mark's rectangle), in page user space.
|
||||
/// The covering rectangle (or the mark's rectangle), in page user space, normalized so
|
||||
/// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`.
|
||||
pub rect: [f64; 4],
|
||||
/// The extractable text the cover hides (possibly empty when nothing decodable remains).
|
||||
/// The extractable text the cover hides, lines joined with single spaces and trimmed.
|
||||
/// Possibly empty when nothing decodable remains under the cover.
|
||||
pub covered_text: String,
|
||||
}
|
||||
|
||||
@@ -76,8 +84,12 @@ pub enum AuditError {
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
}
|
||||
|
||||
/// Fraction of a text line's area that must lie under a cover to count as hidden.
|
||||
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
|
||||
/// cover to count as hidden (see [`covers`]).
|
||||
const COVER_FRACTION: f64 = 0.4;
|
||||
/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however
|
||||
/// long the line around it (see [`covers`]).
|
||||
const WORD_FRACTION: f64 = 0.5;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
|
||||
@@ -106,7 +118,7 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if covers(&annot, &line.rect) {
|
||||
if covers(&annot, line) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
@@ -126,23 +138,41 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
|
||||
/// contrast check and are not flagged.
|
||||
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of `line`?
|
||||
fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool {
|
||||
let ix0 = cover[0].max(line[0]);
|
||||
let iy0 = cover[1].max(line[1]);
|
||||
let ix1 = cover[2].min(line[2]);
|
||||
let iy1 = cover[3].min(line[3]);
|
||||
if ix1 <= ix0 || iy1 <= iy0 {
|
||||
return false;
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
|
||||
///
|
||||
/// - Half of one word lies under the cover. A box over one number in a long line hides a few
|
||||
/// percent of the line, and the number all the same (#2662).
|
||||
///
|
||||
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
|
||||
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
|
||||
/// the text was), and the box still spans it, so a redaction box at the end of a line used to
|
||||
/// cover most of the box and flag the words left beside it.
|
||||
/// - 40% of the line's box lies under the cover (the measure used before `ink`) and intersects the ink
|
||||
/// of a word still drawn. This keeps a cover over a short value after a long label
|
||||
/// flagged, while a cover over the gap alone, with no word under it, stops counting.
|
||||
///
|
||||
/// A line without word boxes is measured on its box alone.
|
||||
fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
let hidden = |b: &[f64; 4]| area(&[cover[0].max(b[0]), cover[1].max(b[1]), cover[2].min(b[2]), cover[3].min(b[3])]);
|
||||
let fraction = |boxes: &[[f64; 4]]| {
|
||||
let total: f64 = boxes.iter().map(area).sum();
|
||||
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
|
||||
};
|
||||
if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) {
|
||||
return true;
|
||||
}
|
||||
let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0);
|
||||
if line_area <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
(ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION
|
||||
let inked = fraction(&line.ink) >= COVER_FRACTION;
|
||||
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
|
||||
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
|
||||
inked || (boxed && (line.ink.is_empty() || word_intersects()))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// WCAG 2.x relative luminance of an sRGB triple.
|
||||
|
||||
+260
-3
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
|
||||
use crate::{FindingKind, audit_redactions};
|
||||
use crate::{AuditFinding, FindingKind, audit_redactions};
|
||||
|
||||
/// Minimal PDF assembler: objects are numbered 1..=n in order.
|
||||
struct Builder {
|
||||
@@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() {
|
||||
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
|
||||
}
|
||||
|
||||
/// #2662: a box over one number of a long line hides a few percent of the line, and all of the
|
||||
/// number. Found as a cover, and as an unapplied mark over the same word.
|
||||
#[test]
|
||||
fn a_cover_over_one_word_of_a_long_line_is_found() {
|
||||
// Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163.
|
||||
let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
";
|
||||
let mut content = line.to_vec();
|
||||
content.extend_from_slice(
|
||||
b"0 g
|
||||
131 96 33 14 re f
|
||||
",
|
||||
);
|
||||
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
assert!(findings[0].covered_text.contains("98765"), "{findings:?}");
|
||||
|
||||
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(line);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
||||
}
|
||||
|
||||
/// A box that only grazes the words beside it (less than half of each) is not a cover.
|
||||
#[test]
|
||||
fn a_box_grazing_a_word_is_not_flagged() {
|
||||
let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
0 g
|
||||
131 96 6 14 re f
|
||||
";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_before_text_is_not_flagged() {
|
||||
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
||||
@@ -228,12 +269,107 @@ fn self_drawing_form_fails_the_audit() {
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
||||
/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the
|
||||
/// text was, then the redaction box. At the end of a line, the box covers most of the line's box
|
||||
/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged.
|
||||
#[test]
|
||||
fn applied_redaction_at_the_end_of_a_line_is_not_flagged() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
// The same gap between two words, wider than the words beside it.
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
/// The control: the same box over text still drawn under it is a faux redaction, at the end of a
|
||||
/// line as in the middle of a short one.
|
||||
#[test]
|
||||
fn faux_redaction_at_the_end_of_a_line_is_found() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
}
|
||||
|
||||
/// A cover over a short value after a long label hides less than 40% of the line's words, but the
|
||||
/// whole of the words it covers, and most of the line's box: still a faux redaction. The value
|
||||
/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`.
|
||||
#[test]
|
||||
fn faux_redaction_over_a_short_value_after_a_long_label_is_found() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789");
|
||||
let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re 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("4111"), "{findings:?}");
|
||||
}
|
||||
|
||||
/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a
|
||||
/// cover over the address it draws is still found.
|
||||
#[test]
|
||||
fn faux_redaction_over_mirrored_text_is_found() {
|
||||
let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
||||
}
|
||||
|
||||
/// A cover over less than half a value must retain the historical line-box detection.
|
||||
#[test]
|
||||
fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() {
|
||||
partial_value_is_found(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() {
|
||||
partial_value_is_found(true);
|
||||
}
|
||||
|
||||
fn partial_value_is_found(rotated: bool) {
|
||||
let bytes = one_page(
|
||||
b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n",
|
||||
if rotated { "/Rotate 90" } else { "" },
|
||||
);
|
||||
let findings = audit_redactions(&open(bytes)).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "partial value, rotated={rotated}");
|
||||
}
|
||||
|
||||
/// Keep the historic detections with several covers, lines and text-spacing states.
|
||||
#[test]
|
||||
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
|
||||
// The second box of the first case hides most of the number (less than 40% of the line): a
|
||||
// finding of its own since covers are also measured per word (#2662).
|
||||
let cases: [(&[u8], usize); 4] = [
|
||||
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", 2),
|
||||
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", 1),
|
||||
(b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1),
|
||||
(b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1),
|
||||
];
|
||||
for (content, expected) in cases {
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_drawn_as_a_path_covers_text_like_re() {
|
||||
// The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or
|
||||
// only by the fill itself), or in a scaled user space: all are faux redactions (#1510).
|
||||
let covers: [&[u8]; 5] = [
|
||||
// So are `re` with a redundant `h`, and either path painted with `b`/`b*` (#1817).
|
||||
let covers: [&[u8]; 10] = [
|
||||
b"0 g\n100 688 220 32 re f\n",
|
||||
b"0 g\n100 688 220 32 re h f\n",
|
||||
b"0 g\n100 688 220 32 re b\n",
|
||||
b"0 g\n100 688 220 32 re h b*\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l b\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h b*\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n",
|
||||
b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n",
|
||||
@@ -253,9 +389,12 @@ fn rectangle_drawn_as_a_path_covers_text_like_re() {
|
||||
#[test]
|
||||
fn non_rectangular_path_is_not_a_cover() {
|
||||
// Each path's bounding box covers the text, but the shape itself does not hide it all.
|
||||
let covers: [&[u8]; 5] = [
|
||||
let covers: [&[u8]; 7] = [
|
||||
// A triangle.
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l h f\n",
|
||||
// A triangle painted with `b`, and a trapezoid with `b*`.
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l b\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l b*\n",
|
||||
// A triangle, then a line from its start point after `h`: not a fourth corner.
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n",
|
||||
// A trapezoid: four corners, one slanted side.
|
||||
@@ -272,3 +411,121 @@ fn non_rectangular_path_is_not_a_cover() {
|
||||
assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finding_kind_ids_are_stable() {
|
||||
// The string IDs are the crate's public vocabulary (CLI output, future machine-readable
|
||||
// formats). They must not change; add a variant instead of renaming.
|
||||
assert_eq!(FindingKind::CoveredText.id(), "covered-text");
|
||||
assert_eq!(FindingKind::UnappliedMark.id(), "unapplied-mark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_text_finding_is_fully_determined() {
|
||||
// Golden test: the whole finding, not just its presence, is pinned.
|
||||
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 findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(
|
||||
findings,
|
||||
vec![AuditFinding {
|
||||
page: 0,
|
||||
kind: FindingKind::CoveredText,
|
||||
rect: [100.0, 688.0, 320.0, 720.0],
|
||||
covered_text: "SECRET DATA 123".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unapplied_mark_finding_is_fully_determined() {
|
||||
// Golden test for the second finding class, with its exact mark rectangle.
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n";
|
||||
let page_extra = "/Annots [6 0 R]";
|
||||
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(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"));
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(
|
||||
findings,
|
||||
vec![AuditFinding { page: 0, kind: FindingKind::UnappliedMark, rect: [95.0, 685.0, 305.0, 725.0], covered_text: "DO NOT SHARE".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
/// A page whose content draws a chain of `n` nested form XObjects. The innermost form draws
|
||||
/// nothing, so a successful audit reports no findings.
|
||||
fn nested_forms(n: usize) -> Vec<u8> {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
// Object 3: page; object 4: content; objects 5..5+n: the form chain.
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /XObject << /F1 5 0 R >> >> >>");
|
||||
b.add_stream(b"/F1 Do");
|
||||
for i in 1..=n {
|
||||
let obj = 4 + i;
|
||||
if i < n {
|
||||
let next = obj + 1;
|
||||
let name = format!("/F{}", i + 1);
|
||||
b.add_stream_with(
|
||||
format!("/{name} Do", name = &name[1..]).as_bytes(),
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /F{i} {next} 0 R >> >>",
|
||||
i = i + 1,
|
||||
next = next
|
||||
),
|
||||
);
|
||||
} else {
|
||||
b.add_stream_with(b"q Q", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]");
|
||||
}
|
||||
debug_assert_eq!(b.objects.len(), obj);
|
||||
}
|
||||
b.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_nested_at_depth_limit_audit_clean() {
|
||||
// 32 nested forms is the deepest walk the audit follows: it must succeed and report
|
||||
// the page clean rather than fail.
|
||||
let findings = audit_redactions(&open(nested_forms(32))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_nested_past_depth_limit_fail_closed() {
|
||||
// The 33rd nesting level cannot be followed: the audit fails rather than report the
|
||||
// page clean with a possible cover left unseen.
|
||||
let result = audit_redactions(&open(nested_forms(33)));
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_across_streams_neither_invents_text_nor_a_cover() {
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f"));
|
||||
assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported");
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
|
||||
let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect();
|
||||
assert!(!text.contains("After comment"), "editor exposed phantom text: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_blocks_respect_stream_comments_and_split_operands() {
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Before comment");
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET"));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Split operand");
|
||||
// Marked content opened in one stream applies to text in the next: its /ActualText replaces
|
||||
// the glyphs, as when the streams are read joined.
|
||||
for (s1, s2) in [
|
||||
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)".as_slice(), b">> BDC (Glyphs) Tj EMC ET".as_slice()),
|
||||
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)>> BDC", b"(Glyphs) Tj EMC ET"),
|
||||
] {
|
||||
let doc = open(two_streams(s1, s2));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,14 @@ 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`.
|
||||
|
||||
## Text search and cache limits
|
||||
|
||||
`text_word_count` counts the current snapshot without filling the text cache. It returns totals
|
||||
and per-page Unicode word/character counts, with optional tagged-table, tagged-code and
|
||||
image/OCR exclusions. See [word count](../../docs/word-count.md) for counting rules and limits.
|
||||
|
||||
Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned
|
||||
text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector
|
||||
capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the
|
||||
@@ -59,6 +64,20 @@ semantics; they never silently act on partial text.
|
||||
|
||||
## Shared field properties
|
||||
|
||||
For an individual push button, `form_set_props` also edits its visible normal label:
|
||||
|
||||
```json
|
||||
{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1}
|
||||
```
|
||||
|
||||
`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the
|
||||
label. Neither renames the field. `form_fields` returns the first widget's `caption` and all
|
||||
`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts
|
||||
keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen.
|
||||
The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and
|
||||
icon layout are not edited here; the existing caption font and unsupported-character warning
|
||||
policy apply.
|
||||
|
||||
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
|
||||
|
||||
```json
|
||||
@@ -70,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali
|
||||
unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
|
||||
unlock and change fields together. Only supplied properties change: each field/widget keeps
|
||||
its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
|
||||
or fill colour. Names, rectangles and rotations require the single-field form. Results keep
|
||||
or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep
|
||||
`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
|
||||
|
||||
## MCP server
|
||||
@@ -152,6 +171,10 @@ For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based i
|
||||
`text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the
|
||||
working document unchanged.
|
||||
|
||||
`page_render.hide_added_text` does the same for an added text item's 1-based index from
|
||||
`content_list` or `object_list`. It removes only that item in a read-only preview and is
|
||||
mutually exclusive with `hide_text_block`.
|
||||
|
||||
|
||||
## Existing text and document fonts
|
||||
|
||||
@@ -184,3 +207,19 @@ unapplied with a warning.
|
||||
`doc_batch` runs conversion, OCR and compression over files and optionally recursive
|
||||
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
|
||||
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.
|
||||
|
||||
## Viewer navigation history
|
||||
|
||||
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
|
||||
`pdfcraft-cli ui --control FILE command id=view.previous_view` and
|
||||
`pdfcraft-cli ui --control FILE command id=view.next_view`. `commands` reports whether each action
|
||||
is enabled for the active document. These are the same actions as the rail buttons, View → Page
|
||||
navigation, and Ctrl+[ / Ctrl+] (⌘[ / ⌘] on macOS).
|
||||
|
||||
Explicit page jumps, including search-result selection and Next/Previous match, enter history
|
||||
when the page changes. Automatic search refresh, continuous scrolling, and within-page scroll
|
||||
movement do not. Existing page-step commands and single-page wheel turns retain their history
|
||||
behavior. Back/Forward
|
||||
returns to page tops, does not restore zoom, does not switch documents, and is not saved across
|
||||
restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate
|
||||
navigation-history tool; use the opt-in UI control channel to navigate the displayed document.
|
||||
@@ -202,6 +202,10 @@ fn review_html(record: &Value) -> Result<String> {
|
||||
Ok(html)
|
||||
}
|
||||
|
||||
/// Tags in one page of `accessibility_tags`, by default and at most.
|
||||
const TAG_PAGE: usize = 200;
|
||||
const MAX_TAG_PAGE: usize = 1000;
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
@@ -213,6 +217,19 @@ impl Automation {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?;
|
||||
let report = self
|
||||
.doc(a)?
|
||||
.accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None })
|
||||
.ok_or_else(|| failed("the document can't be read"))?;
|
||||
let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?;
|
||||
Ok(
|
||||
json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?;
|
||||
@@ -269,6 +286,14 @@ impl Automation {
|
||||
Ok(report_json(&r))
|
||||
}
|
||||
|
||||
/// Report only untagged page content, annotations, and AcroForm widgets.
|
||||
pub(crate) fn a11y_find_untagged(&self, a: &Args) -> Result<Value> {
|
||||
let pages = a.opt_ints("pages")?.map(|_| self.pages(a, "pages")).transpose()?;
|
||||
let rules = [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields].into_iter().collect();
|
||||
let report = self.doc(a)?.accessibility_check(&Options { rules, pages }).ok_or_else(|| failed("the document can't be read for checking"))?;
|
||||
Ok(report_json(&report))
|
||||
}
|
||||
|
||||
pub(crate) fn a11y_report(&self, a: &Args) -> Result<Value> {
|
||||
let o = self.a11y_options(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
@@ -441,11 +466,39 @@ impl Automation {
|
||||
self.accessibility_figures(a)
|
||||
}
|
||||
|
||||
/// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages
|
||||
/// (1-based) or depths. `count` is how many match; `next` is where the following page starts.
|
||||
pub(crate) fn accessibility_tags(&self, a: &Args) -> Result<Value> {
|
||||
let tags: Vec<Value> = self
|
||||
let offset = usize::try_from(a.opt_int("offset")?.unwrap_or(0)).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(TAG_PAGE as i64);
|
||||
let limit = usize::try_from(limit)
|
||||
.ok()
|
||||
.filter(|l| (1..=MAX_TAG_PAGE).contains(l))
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_TAG_PAGE}")))?;
|
||||
let types: Option<Vec<&str>> = a.get("types").map(|_| a.strs("types")).transpose()?;
|
||||
let pages: Option<Vec<usize>> = a
|
||||
.opt_ints("pages")?
|
||||
.map(|p| p.into_iter().map(|n| usize::try_from(n).ok().filter(|n| *n >= 1).map(|n| n - 1)).collect::<Option<Vec<_>>>())
|
||||
.map(|p| p.ok_or_else(|| ToolError::InvalidArgs("pages must be 1-based page numbers".into())))
|
||||
.transpose()?;
|
||||
let max_depth = a
|
||||
.opt_int("max_depth")?
|
||||
.map(|d| usize::try_from(d).map_err(|_| ToolError::InvalidArgs("max_depth must be 0 or more".into())))
|
||||
.transpose()?;
|
||||
let matching: Vec<_> = self
|
||||
.doc(a)?
|
||||
.tags()
|
||||
.into_iter()
|
||||
.filter(|t| types.as_ref().is_none_or(|ty| ty.contains(&t.kind.as_str())))
|
||||
.filter(|t| pages.as_ref().is_none_or(|p| t.page.is_some_and(|page| p.contains(&page))))
|
||||
.filter(|t| max_depth.is_none_or(|d| t.depth <= d))
|
||||
.collect();
|
||||
let count = matching.len();
|
||||
let next = offset.checked_add(limit).filter(|n| *n < count);
|
||||
let tags: Vec<Value> = matching
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|t| {
|
||||
json!({
|
||||
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
|
||||
@@ -455,7 +508,17 @@ impl Automation {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "count": tags.len(), "tags": tags }))
|
||||
Ok(json!({ "count": count, "tags": tags, "next": next }))
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_tag_content(&self, a: &Args) -> Result<Value> {
|
||||
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
|
||||
let generation =
|
||||
u16::try_from(a.int("generation")?).map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation".into()))?;
|
||||
let content = self.doc(a)?.tag_content(pdfcraft_cos::ObjRef { num: object, generation }).map_err(failed)?;
|
||||
Ok(
|
||||
json!({ "count": content.len(), "content": content.into_iter().map(|item| json!({ "page": item.page.map(|p| p + 1), "mcid": item.mcid, "text": item.text })).collect::<Vec<_>>() }),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
|
||||
@@ -476,6 +539,18 @@ impl Automation {
|
||||
self.accessibility_tags(a)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_mark_tag_artifact(&mut self, a: &Args) -> Result<Value> {
|
||||
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
|
||||
if object == 0 {
|
||||
return Err(ToolError::InvalidArgs("object must be a positive tag object number".into()));
|
||||
}
|
||||
let generation = u16::try_from(a.int("generation")?)
|
||||
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) }).map_err(failed)?;
|
||||
self.accessibility_tags(a)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result<Value> {
|
||||
let reference = |number: &str, generation: &str| -> Result<pdfcraft_cos::ObjRef> {
|
||||
let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?;
|
||||
@@ -524,7 +599,7 @@ impl Automation {
|
||||
// other tools (the write check passes missing files, which stay per-file errors below).
|
||||
let paths = a.strs("paths")?;
|
||||
for p in &paths {
|
||||
self.resolve(p, true)?;
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
@@ -843,11 +918,16 @@ impl Automation {
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let kind = match c.kind {
|
||||
pdfcraft_engine::detect::Kind::Text => "text",
|
||||
pdfcraft_engine::detect::Kind::CheckBox => "checkbox",
|
||||
let (kind, comb) = match c.kind {
|
||||
pdfcraft_engine::detect::Kind::Text => ("text", None),
|
||||
pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
|
||||
pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
|
||||
};
|
||||
json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
|
||||
let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
|
||||
if let Some(cells) = comb {
|
||||
v["comb"] = json!(cells);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
|
||||
|
||||
@@ -511,7 +511,8 @@ impl Automation {
|
||||
let text_at = |t: &str| {
|
||||
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
|
||||
let h = size * 1.2 + 6.0;
|
||||
Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size }
|
||||
let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
|
||||
Shape::Typewriter { rect, font_size: size }
|
||||
};
|
||||
let (shape, contents) = match kind {
|
||||
"text" => {
|
||||
@@ -618,6 +619,12 @@ impl Automation {
|
||||
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
|
||||
edits.push(Edit::FillAnnotation { page, index, fill });
|
||||
}
|
||||
if let Some(size) = a.opt_num("font_size")? {
|
||||
if !(1.0..=400.0).contains(&size) {
|
||||
return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into()));
|
||||
}
|
||||
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) });
|
||||
}
|
||||
@@ -626,7 +633,7 @@ impl Automation {
|
||||
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, rect or move".into()));
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into()));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
self.apply(a, edit)
|
||||
|
||||
@@ -69,7 +69,7 @@ impl Automation {
|
||||
let doc = self.doc(a)?;
|
||||
let mut per_page: std::collections::HashMap<usize, usize> = Default::default();
|
||||
let items: Vec<Value> = doc
|
||||
.added
|
||||
.added()
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let index = per_page.entry(it.page).or_default();
|
||||
@@ -110,7 +110,7 @@ impl Automation {
|
||||
let mut t = AddedText { rect, text: a.str("text")?.to_string(), ..AddedText::default() };
|
||||
style(a, &mut t)?;
|
||||
let mut out = self.apply(a, Edit::AddText { page, text: t })?;
|
||||
out["index"] = json!(self.doc(a)?.added.iter().filter(|x| x.page == page).count());
|
||||
out["index"] = json!(self.doc(a)?.added().iter().filter(|x| x.page == page).count());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Automation {
|
||||
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) })?;
|
||||
let doc = self.doc(a)?;
|
||||
let mine: Vec<_> = doc.added.iter().filter(|x| x.page == page).collect();
|
||||
let mine: Vec<_> = doc.added().iter().filter(|x| x.page == page).collect();
|
||||
out["index"] = json!(mine.len());
|
||||
out["rect"] = json!(mine.last().map(|x| to_view(x.content.rect(), ph)));
|
||||
Ok(out)
|
||||
@@ -135,10 +135,10 @@ impl Automation {
|
||||
let index = a.int("index")?;
|
||||
let ph = doc.info.pages[page].height as f64;
|
||||
let it = doc
|
||||
.added
|
||||
.added()
|
||||
.iter()
|
||||
.filter(|x| x.page == page)
|
||||
.nth((index - 1).max(0) as usize)
|
||||
.nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX))
|
||||
.filter(|_| index >= 1)
|
||||
.ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?;
|
||||
Ok((page, index as usize - 1, it.content.clone(), ph))
|
||||
|
||||
@@ -2,15 +2,31 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools};
|
||||
use pdfcraft_engine::commands;
|
||||
use pdfcraft_render::{RenderRequest, RequestKind};
|
||||
|
||||
/// The most steps one `command_batch` runs.
|
||||
pub(crate) const MAX_BATCH_STEPS: usize = 1000;
|
||||
|
||||
/// Why `id` has no tool to run: a tool name passed as a command id, an id the registry doesn't
|
||||
/// know, or a real command that nothing automates yet. They need different fixes from the caller.
|
||||
fn no_tool_for(id: &str) -> ToolError {
|
||||
if commands::command(id).is_some() {
|
||||
return failed(format!("command {id:?} has no headless tool; see command_list"));
|
||||
}
|
||||
match tools::find(id) {
|
||||
Some(def) => match def.command {
|
||||
Some(command) => failed(format!("{id:?} is a tool, not a command id; use the command id {command:?}, or call the tool directly")),
|
||||
None => failed(format!("{id:?} is a tool, not a command id; call it directly (command ids are in command_list)")),
|
||||
},
|
||||
None => failed(format!("unknown command id {id:?}; see command_list")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn command_run(&mut self, args: &Args) -> Result<Vec<Content>> {
|
||||
let id = args.str("id")?;
|
||||
let tool = tools::tool_for_command(id).ok_or_else(|| failed(format!("command {id:?} has no headless tool; see command_list")))?;
|
||||
let tool = tools::tool_for_command(id).ok_or_else(|| no_tool_for(id))?;
|
||||
let def = tools::find(tool).ok_or_else(|| failed("command tool is unavailable"))?;
|
||||
let mut params = match args.get("params") {
|
||||
None | Some(Value::Null) => serde_json::Map::new(),
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `pdfcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's tools reach, with the names and wording VectorCraft uses. [`AutomationRoots`]
|
||||
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
|
||||
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! 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).
|
||||
//! - 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.
|
||||
//!
|
||||
//! Without roots nothing is checked. A path is checked, then opened: a process that can change the
|
||||
//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the
|
||||
//! files it was given; they don't guard against other programs on the computer.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Read access or write access.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Root {
|
||||
/// The folder made absolute, links followed: what paths are compared with.
|
||||
resolved: PathBuf,
|
||||
/// The same without Windows' `\\?\` prefix, for people.
|
||||
shown: PathBuf,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The folder at `path`, which must exist.
|
||||
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
||||
let kind = match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
};
|
||||
let resolved = std::fs::canonicalize(path)
|
||||
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
||||
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
||||
Ok(Self { shown: plain(resolved.clone()), resolved })
|
||||
}
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
self.check_path(path, Path::new(path), access)
|
||||
}
|
||||
|
||||
/// [`check`](Self::check) for a path that was already put together from `shown` (the way the
|
||||
/// caller wrote it, which is what refusals quote).
|
||||
pub fn check_path(&self, shown: &str, target: &Path, access: Access) -> Result<(), String> {
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
};
|
||||
let Some(Root { resolved: folder, shown: root_shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(shown, target)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = root_shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
|
||||
fn plain(path: PathBuf) -> PathBuf {
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_string_lossy();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
match text.strip_prefix(r"\\?\") {
|
||||
Some(rest) => PathBuf::from(rest),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
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}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
found.extend(missing.iter().rev());
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
match (at.components().next_back(), parent) {
|
||||
(Some(Component::Normal(name)), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
/// Windows, whose file systems ignore it)?
|
||||
fn inside(path: &Path, root: &Path) -> bool {
|
||||
let mut path = path.components();
|
||||
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
||||
}
|
||||
|
||||
fn same_component(a: Component, b: Component) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
||||
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
||||
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
||||
let base = std::env::temp_dir().join(format!("pc-file-access-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in/sub", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.pdf"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.pdf"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
fn s(p: PathBuf) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_inside_the_roots_pass_and_others_dont() {
|
||||
let (base, roots) = folders("inside");
|
||||
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
||||
assert_eq!(roots.check(&s(base.join("in/sub/new.pdf")), Access::Read), Ok(()), "missing files are checked by their folder");
|
||||
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
||||
let e = roots.check(&s(base.join("outside/secret.pdf")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
||||
// Read and write access are separate.
|
||||
let e = roots.check(&s(base.join("in/a.pdf")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
std::fs::write(base.join("in2/b.pdf"), "x").unwrap();
|
||||
assert!(roots.check(&s(base.join("in2/b.pdf")), Access::Read).is_err(), "in2 is not inside in");
|
||||
assert!(roots.check(&format!("{}2/c.pdf", s(base.join("in"))), Access::Read).is_err());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_traversal_is_resolved_or_refused() {
|
||||
let (base, roots) = folders("dotdot");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.pdf", s(base.join("in")));
|
||||
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
||||
let back = format!("{}{sep}..{sep}a.pdf", s(base.join("in/sub")));
|
||||
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
||||
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.pdf", s(base.join("out")));
|
||||
let e = roots.check(&missing, Access::Write).unwrap_err();
|
||||
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
||||
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_working_directory() {
|
||||
let (base, roots) = folders("relative");
|
||||
// The tests' working directory is the crate's folder: not inside the roots.
|
||||
assert!(roots.check("a.pdf", Access::Read).unwrap_err().contains("outside the read root"));
|
||||
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.pdf"))).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.pdf")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_followed_before_comparing() {
|
||||
let (base, roots) = folders("links");
|
||||
#[cfg(unix)]
|
||||
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let made = |target: &Path, link: &Path| {
|
||||
let made =
|
||||
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
||||
made.is_ok()
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let made = |_: &Path, _: &Path| false;
|
||||
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
||||
if !made(&base.join("outside/secret.pdf"), &base.join("in/leak.pdf")) {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
return;
|
||||
}
|
||||
assert!(roots.check(&s(base.join("in/leak.pdf")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
||||
assert!(made(&base.join("outside"), &base.join("out/away")));
|
||||
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
||||
assert!(made(&base.join("outside/gone.pdf"), &base.join("out/dangling.png")));
|
||||
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
||||
// A link to a folder inside the root stays inside.
|
||||
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.pdf")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
let (base, roots) = folders("case");
|
||||
let upper = s(base.join("IN/A.PDF"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(&s(base.join("In/Sub/New.pdf")), Access::Read), Ok(()));
|
||||
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
||||
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the
|
||||
/// roots. A function that reaches for the file system without resolving a path first would
|
||||
/// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in
|
||||
/// the same function (or be one of the helpers that only ever get a resolved path).
|
||||
#[test]
|
||||
fn tools_do_not_touch_the_file_system_without_resolving_the_path() {
|
||||
// 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"];
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = vec![];
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_none_or(|e| e != "rs")
|
||||
|| path.file_name().is_some_and(|n| n == "file_access.rs" || n.to_string_lossy().starts_with("tests"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// Test modules (which build their own fixtures) come last in each file.
|
||||
let code = text.split("\n#[cfg(test)]").next().unwrap_or("");
|
||||
let lines: Vec<&str> = code.lines().collect();
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//")
|
||||
|| !(trimmed.contains("std::fs")
|
||||
|| trimmed.contains("File::open")
|
||||
|| trimmed.contains("File::create")
|
||||
|| trimmed.contains("OpenOptions"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let start = (0..=n).rev().find(|&i| lines[i].trim_start().contains("fn ") && lines[i].trim_start().ends_with('{')).unwrap_or(0);
|
||||
let name = lines[start].split("fn ").nth(1).and_then(|r| r.split(['(', '<']).next()).unwrap_or("");
|
||||
if !RESOLVED_BY_CALLER.contains(&name) && !lines[start..=n].iter().any(|l| l.contains("resolve")) {
|
||||
offenders.push(format!("{}:{}: in `{name}`: {}", path.display(), n + 1, trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(offenders.is_empty(), "resolve the path with `self.resolve` first: {offenders:#?}");
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,19 @@ impl Automation {
|
||||
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
|
||||
}
|
||||
match f.kind {
|
||||
FormFieldKind::PushButton => {
|
||||
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
|
||||
o.insert(
|
||||
"captions".into(),
|
||||
json!(
|
||||
f.widgets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) })
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
}
|
||||
FormFieldKind::Radio => {
|
||||
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
|
||||
}
|
||||
@@ -327,6 +340,9 @@ impl Automation {
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
let single = a.opt_str("field")?;
|
||||
let bulk = a.get("fields").is_some();
|
||||
if single.is_some() == bulk {
|
||||
@@ -344,7 +360,7 @@ impl Automation {
|
||||
return Err(bad(format!("field {name:?} is listed more than once")));
|
||||
}
|
||||
}
|
||||
for key in ["name", "rect", "rotation"] {
|
||||
for key in ["name", "rect", "rotation", "caption", "widget"] {
|
||||
if a.get(key).is_some() {
|
||||
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
|
||||
}
|
||||
@@ -398,6 +414,15 @@ impl Automation {
|
||||
rect,
|
||||
name: a.opt_str("name")?.map(str::to_owned),
|
||||
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
|
||||
caption: a
|
||||
.opt_str("caption")?
|
||||
.map(|caption| -> Result<_> {
|
||||
let wi = a.opt_int("widget")?.unwrap_or(1);
|
||||
let wi =
|
||||
usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
|
||||
Ok((wi, caption.to_owned()))
|
||||
})
|
||||
.transpose()?,
|
||||
read_only: a.opt_bool("read_only")?,
|
||||
required: a.opt_bool("required")?,
|
||||
multiline: a.opt_bool("multiline")?,
|
||||
|
||||
+361
-50
@@ -7,7 +7,10 @@
|
||||
//! server ([`mcp`]), `pdfcraft-cli run` and (later) the UI control channel.
|
||||
//! - Pages are **1-based** in every tool, as people number them. Rectangles are in PDF points
|
||||
//! with the origin at the top-left of the displayed page.
|
||||
//! - An optional root directory confines every path a tool reads or writes.
|
||||
//! - An optional root directory confines every path a tool reads or writes. Separately,
|
||||
//! [`file_access::AutomationRoots`] (`pdfcraft-cli mcp --automation-read-root` and
|
||||
//! `--automation-write-root`) holds a read folder and a write folder, each left out meaning no
|
||||
//! access of its kind.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -16,6 +19,7 @@ mod batch;
|
||||
mod comments;
|
||||
mod content;
|
||||
mod conventions;
|
||||
pub mod file_access;
|
||||
mod forms;
|
||||
mod links;
|
||||
#[cfg(feature = "mcp")]
|
||||
@@ -33,10 +37,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands};
|
||||
use pdfcraft_platform::staging::{StagingName, create_staging, staging_suffixes};
|
||||
use pdfcraft_platform::staging::{StagingName, staging_suffixes};
|
||||
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use pdfcraft_render::Pixels;
|
||||
pub use tools::{ToolDef, tools};
|
||||
|
||||
/// One piece of a tool's result.
|
||||
@@ -107,6 +112,8 @@ const FIND_MAX_WARNING_BYTES: usize = 1024;
|
||||
pub struct Automation {
|
||||
session: Session,
|
||||
root: Option<PathBuf>,
|
||||
/// The read and write folders of `--automation-read-root` / `--automation-write-root`.
|
||||
roots: Option<Arc<file_access::AutomationRoots>>,
|
||||
/// Synchronous renderers, rebuilt when a document's working bytes change.
|
||||
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
|
||||
/// Extracted page text, under one memory budget across all open documents.
|
||||
@@ -121,7 +128,7 @@ impl Default for Automation {
|
||||
|
||||
impl Automation {
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
}
|
||||
|
||||
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
|
||||
@@ -134,26 +141,42 @@ impl Automation {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Confine the files the tools read to the read root and the files they write to the write
|
||||
/// root (`None`: no confinement). Applies on top of [`with_root`](Self::with_root) when both
|
||||
/// are set.
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<file_access::AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use a fixed clock for saves (deterministic output in tests).
|
||||
pub fn with_clock(mut self, clock: fn() -> i64) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_clock(clock);
|
||||
self
|
||||
}
|
||||
|
||||
/// The clock and time zone of a host without its own. See [`Session::with_wall_clock`].
|
||||
pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn session(&self) -> &Session {
|
||||
&self.session
|
||||
}
|
||||
|
||||
/// Save `bytes` that a caller writes on the session's behalf, such as `pdfcraft-cli run`
|
||||
/// saving a rendered page. With a root, it is confined like a tool's own output (a relative
|
||||
/// path resolves inside it) and written atomically. Without one, the path is written as
|
||||
/// given, so `/dev/stdout` and the like still work. Returns where the file went.
|
||||
/// path resolves inside it). Written atomically either way (#2166), except to something
|
||||
/// that is not a regular file, such as `/dev/stdout` or a pipe, which is written as given.
|
||||
/// Returns where the file went.
|
||||
pub fn write_output(&self, path: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
let target = self.resolve(path, true)?;
|
||||
if self.root.is_some() {
|
||||
write_atomic(&target, bytes)?;
|
||||
} else {
|
||||
let special = self.root.is_none() && self.roots.is_none() && std::fs::metadata(&target).is_ok_and(|m| !m.is_file() && !m.is_dir());
|
||||
if special {
|
||||
std::fs::write(&target, bytes).map_err(|e| failed(format!("{path}: {e}")))?;
|
||||
} else {
|
||||
write_atomic(&target, bytes)?;
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
@@ -186,6 +209,7 @@ impl Automation {
|
||||
"page_render" => return self.page_render(&a).map(|c| vec![c]),
|
||||
"comment_image_preview" => return self.comment_image_preview(&a),
|
||||
"text_extract" => self.text_extract(&a)?,
|
||||
"text_word_count" => self.text_word_count(&a)?,
|
||||
"text_find" => self.text_find(&a)?,
|
||||
"page_rotate" => {
|
||||
let degrees = a.int("degrees")?;
|
||||
@@ -259,6 +283,13 @@ impl Automation {
|
||||
let to = self.position(&a, "to")?;
|
||||
self.apply(&a, Edit::MovePages { pages, to })?
|
||||
}
|
||||
"page_reverse" => {
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(&a, "pages")?,
|
||||
None => (0..self.doc(&a)?.info.pages.len()).collect(),
|
||||
};
|
||||
self.apply(&a, Edit::ReversePages { pages })?
|
||||
}
|
||||
"page_insert_blank" => self.insert_blank(&a)?,
|
||||
"page_insert_file" => self.insert_file(&a)?,
|
||||
"page_extract" => self.page_extract(&a)?,
|
||||
@@ -349,10 +380,13 @@ impl Automation {
|
||||
}
|
||||
"page_duplicate" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
self.apply(&a, Edit::DuplicatePages { pages })?
|
||||
let at = self.opt_position(&a, "at")?;
|
||||
self.apply(&a, Edit::DuplicatePages { pages, at })?
|
||||
}
|
||||
"page_set_box" => self.page_set_box(&a)?,
|
||||
"page_transition_set" => self.page_transition_set(&a)?,
|
||||
"doc_create" => self.doc_create(&a)?,
|
||||
"xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?,
|
||||
"doc_flatten" => {
|
||||
let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true));
|
||||
if !comments && !fields {
|
||||
@@ -466,6 +500,15 @@ impl Automation {
|
||||
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" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32),
|
||||
"crop" => {
|
||||
let r: Vec<f64> =
|
||||
a.get("rect").and_then(Value::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("crop needs rect: 4 numbers, the part to keep".into()))?;
|
||||
let info = &self.doc(&a)?.info.pages[page];
|
||||
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::Crop([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])
|
||||
}
|
||||
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
|
||||
"flip_vertical" => ImageEdit::Flip { horizontal: false },
|
||||
"delete" => ImageEdit::Delete,
|
||||
@@ -517,6 +560,12 @@ impl Automation {
|
||||
.iter()
|
||||
.map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if let Some(roots) = &self.roots {
|
||||
// The installed fonts are outside the roots: look in the read root instead.
|
||||
let read = roots
|
||||
.read_root()
|
||||
.ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?;
|
||||
vec![self.resolve(&read.to_string_lossy(), false)?]
|
||||
} else if let Some(root) = &self.root {
|
||||
vec![root.clone()]
|
||||
} else {
|
||||
@@ -722,8 +771,10 @@ impl Automation {
|
||||
"comment_set_status" => self.comment_set_status(&a)?,
|
||||
"sign_list" => self.sign_list(&a)?,
|
||||
"accessibility_check" => self.a11y_check(&a)?,
|
||||
"accessibility_find_untagged" => self.a11y_find_untagged(&a)?,
|
||||
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
|
||||
"accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?,
|
||||
"accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?,
|
||||
"ocr_recognize" => self.ocr_recognize(&a)?,
|
||||
"js_run" => self.js_run(&a)?,
|
||||
"js_document_scripts" => self.js_document_scripts(&a)?,
|
||||
@@ -754,7 +805,9 @@ impl Automation {
|
||||
"accessibility_figures" => self.accessibility_figures(&a)?,
|
||||
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
|
||||
"accessibility_tags" => self.accessibility_tags(&a)?,
|
||||
"accessibility_tag_content" => self.accessibility_tag_content(&a)?,
|
||||
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
|
||||
"accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?,
|
||||
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
|
||||
"sign_id_create" => self.sign_id_create(&a)?,
|
||||
"sign_document" => self.sign_document(&a)?,
|
||||
@@ -819,7 +872,8 @@ impl Automation {
|
||||
.session
|
||||
.bookmark_page(id, offset, limit)
|
||||
.ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?;
|
||||
Ok(json!({ "bookmarks": nest_rows(&page.rows), "next": page.next, "truncated": page.truncated }))
|
||||
let pages = &self.doc(a)?.info.pages;
|
||||
Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated }))
|
||||
}
|
||||
|
||||
fn doc(&self, a: &Args) -> Result<&Document> {
|
||||
@@ -866,7 +920,14 @@ impl Automation {
|
||||
let id = doc.id;
|
||||
let target = match a.opt_str("path")? {
|
||||
Some(p) => self.resolve(p, true)?,
|
||||
None => PathBuf::from(doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?),
|
||||
None => {
|
||||
// Saving in place writes where the file was opened from, which the read root
|
||||
// allows and the write root may not.
|
||||
let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?;
|
||||
let own_path = PathBuf::from(&own);
|
||||
self.check_roots(&own, &own_path, file_access::Access::Write)?;
|
||||
own_path
|
||||
}
|
||||
};
|
||||
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
|
||||
// Saving to a new file is a full rewrite unless asked otherwise, like Save As.
|
||||
@@ -879,11 +940,24 @@ impl Automation {
|
||||
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
|
||||
// redaction), is rewritten in full even when an incremental save was asked for.
|
||||
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
|
||||
self.publish_save(id, &target, bytes, incremental, write)
|
||||
}
|
||||
|
||||
/// Validate the prepared bytes before publishing them or marking the document saved.
|
||||
fn publish_save(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
target: &Path,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
incremental: bool,
|
||||
write: impl FnOnce(&Path, &[u8]) -> Result<()>,
|
||||
) -> Result<Value> {
|
||||
self.session.check_save(id, &bytes).map_err(failed)?;
|
||||
write(&target, &bytes)?;
|
||||
write(target, &bytes)?;
|
||||
let path = target.to_string_lossy().into_owned();
|
||||
self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) }))
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) }))
|
||||
}
|
||||
|
||||
fn doc_revisions(&mut self, a: &Args) -> Result<Value> {
|
||||
@@ -993,11 +1067,14 @@ impl Automation {
|
||||
}
|
||||
|
||||
fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::optimize::{Compression, ImageSettings, Settings};
|
||||
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset};
|
||||
let id = self.doc(a)?.id;
|
||||
let before = self.doc(a)?.bytes.len();
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let mut settings = Settings::default();
|
||||
let mut settings = match a.opt_str("preset")? {
|
||||
None => Preset::Standard.settings(),
|
||||
Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(),
|
||||
};
|
||||
let images = |key: &str, base: ImageSettings| -> Result<ImageSettings> {
|
||||
let Some(v) = a.get(key) else { return Ok(base) };
|
||||
let mut s = base;
|
||||
@@ -1025,6 +1102,7 @@ impl Automation {
|
||||
settings.color = images("color", settings.color)?;
|
||||
settings.gray = images("gray", settings.gray)?;
|
||||
for (key, flag) in [
|
||||
("merge_fragments", &mut settings.merge_fragments),
|
||||
("discard_thumbnails", &mut settings.discard_thumbnails),
|
||||
("discard_alternate_images", &mut settings.discard_alternate_images),
|
||||
("discard_tags", &mut settings.discard_tags),
|
||||
@@ -1057,6 +1135,8 @@ impl Automation {
|
||||
"path": path.to_string_lossy(),
|
||||
"bytes_before": before,
|
||||
"bytes_after": bytes.len(),
|
||||
"merged_images": o.merged_images,
|
||||
"fragments_merged": o.fragments_merged,
|
||||
"images": o.images,
|
||||
"images_resampled": o.images_resampled,
|
||||
"images_recompressed": o.images_recompressed,
|
||||
@@ -1197,7 +1277,7 @@ impl Automation {
|
||||
let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?;
|
||||
let mut files = Vec::new();
|
||||
for (k, img) in out.images.iter().enumerate() {
|
||||
let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1));
|
||||
let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len()));
|
||||
write_atomic(&path, &img.data)?;
|
||||
files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height }));
|
||||
}
|
||||
@@ -1279,6 +1359,58 @@ impl Automation {
|
||||
Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?))
|
||||
}
|
||||
|
||||
fn page_transition_set(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{Transition, TransitionSpec};
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let transition = match a.opt_str("style")? {
|
||||
None | Some("none") => None,
|
||||
Some(style) => {
|
||||
let mut t = Transition::new(style);
|
||||
if let Some(d) = a.opt_num("duration")? {
|
||||
t.duration = d as f32;
|
||||
}
|
||||
if let Some(dm) = a.opt_str("dimension")? {
|
||||
t.horizontal = match dm {
|
||||
"horizontal" => true,
|
||||
"vertical" => false,
|
||||
other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))),
|
||||
};
|
||||
}
|
||||
if let Some(m) = a.opt_str("motion")? {
|
||||
t.inward = match m {
|
||||
"inward" => true,
|
||||
"outward" => false,
|
||||
other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))),
|
||||
};
|
||||
}
|
||||
match a.get("direction") {
|
||||
None => {}
|
||||
Some(Value::String(s)) if s == "none" => t.direction = None,
|
||||
Some(v) => {
|
||||
t.direction = Some(
|
||||
v.as_u64()
|
||||
.and_then(|d| u16::try_from(d).ok())
|
||||
.ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(s) = a.opt_num("scale")? {
|
||||
t.scale = s as f32;
|
||||
}
|
||||
if let Some(b) = a.opt_bool("opaque")? {
|
||||
t.opaque = b;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let display = a.opt_num("display")?.map(|s| s as f32);
|
||||
self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } })
|
||||
}
|
||||
|
||||
fn page_set_box(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{BoxSpec, PageBox};
|
||||
let doc = self.doc(a)?;
|
||||
@@ -1622,16 +1754,21 @@ impl Automation {
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for (i, (first, last, bytes)) in parts.iter().enumerate() {
|
||||
let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::<String>();
|
||||
let file = match titles.iter().find(|t| t.0 + 1 == *first) {
|
||||
Some((_, t)) => format!("{stem}-{}.pdf", safe(t)),
|
||||
None => format!("{stem}-part{}.pdf", i + 1),
|
||||
let mut base = match titles.iter().find(|t| t.0 + 1 == *first) {
|
||||
Some((_, t)) => format!("{stem}-{}", safe(t)),
|
||||
None => format!("{stem}-part{}", i + 1),
|
||||
};
|
||||
// A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix.
|
||||
if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) {
|
||||
base.truncate(end);
|
||||
}
|
||||
let file = format!("{base}.pdf");
|
||||
// Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively.
|
||||
let mut unique = file.clone();
|
||||
let mut unique = file;
|
||||
let mut n = 1;
|
||||
while !used.insert(unique.to_lowercase()) {
|
||||
n += 1;
|
||||
unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf"));
|
||||
unique = format!("{base}-{n}.pdf");
|
||||
}
|
||||
let path = child(&dir, &unique);
|
||||
write_atomic(&path, bytes)?;
|
||||
@@ -1657,12 +1794,17 @@ impl Automation {
|
||||
|
||||
/// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based.
|
||||
fn position(&self, a: &Args, key: &str) -> Result<usize> {
|
||||
self.opt_position(a, key)?.ok_or_else(|| Args::missing(key))
|
||||
}
|
||||
|
||||
/// `position`, but `None` when the key is absent.
|
||||
fn opt_position(&self, a: &Args, key: &str) -> Result<Option<usize>> {
|
||||
let Some(at) = a.opt_int(key)? else { return Ok(None) };
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let at = a.int(key)?;
|
||||
if at < 1 || at as usize > n + 1 {
|
||||
return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1)));
|
||||
}
|
||||
Ok(at as usize - 1)
|
||||
Ok(Some(at as usize - 1))
|
||||
}
|
||||
|
||||
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
|
||||
@@ -1687,12 +1829,23 @@ impl Automation {
|
||||
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
|
||||
}
|
||||
let scale = (dpi / 72.0) as f32;
|
||||
let out = if let Some(block) = a.opt_int("hide_text_block")? {
|
||||
let block = a.opt_int("hide_text_block")?;
|
||||
let added = a.opt_int("hide_added_text")?;
|
||||
if block.is_some() && added.is_some() {
|
||||
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)?
|
||||
} else if let Some(added) = added {
|
||||
let index = usize::try_from(added)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?;
|
||||
self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
} else {
|
||||
self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false })
|
||||
};
|
||||
@@ -1703,6 +1856,27 @@ impl Automation {
|
||||
Ok(Content::Png { data, width: out.width, height: out.height })
|
||||
}
|
||||
|
||||
/// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels
|
||||
/// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas:
|
||||
/// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted
|
||||
/// work on every scroll. It uses the same renderer, and the same per-document cache, as
|
||||
/// `page_render`.
|
||||
pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> {
|
||||
if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) {
|
||||
return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0)));
|
||||
}
|
||||
let id = DocId(doc);
|
||||
let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len();
|
||||
if page < 1 || page > n {
|
||||
return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages")));
|
||||
}
|
||||
let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false });
|
||||
if let Some(e) = out.error {
|
||||
return Err(failed(format!("page {page} could not be rendered: {e}")));
|
||||
}
|
||||
Ok((out.width, out.height, out.rgba))
|
||||
}
|
||||
|
||||
fn page(&self, a: &Args) -> Result<usize> {
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let p = a.int("page")?;
|
||||
@@ -1757,6 +1931,28 @@ impl Automation {
|
||||
Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect())
|
||||
}
|
||||
|
||||
fn text_word_count(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::word_count::{self, Counts, Options};
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
};
|
||||
let options = Options {
|
||||
exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false),
|
||||
exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false),
|
||||
exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false),
|
||||
};
|
||||
let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?;
|
||||
let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces });
|
||||
Ok(json!({
|
||||
"total": counts(report.total),
|
||||
"pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::<Vec<_>>(),
|
||||
"pages_without_text": report.pages_without_text,
|
||||
"unclassified_structure": report.unclassified_structure,
|
||||
}))
|
||||
}
|
||||
|
||||
fn text_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let id = doc.id;
|
||||
@@ -1882,6 +2078,29 @@ impl Automation {
|
||||
/// namespace is refused without touching it, and the deepest existing ancestor (links
|
||||
/// followed) must be inside the root before anything about the rest is reported.
|
||||
fn resolve(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let access = if for_write { file_access::Access::Write } else { file_access::Access::Read };
|
||||
self.resolve_for(path, access, for_write)
|
||||
}
|
||||
|
||||
/// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that
|
||||
/// is only read may still be missing (a path checked up front, whose absence is reported per
|
||||
/// file later). The automation roots, if any, get the last word.
|
||||
fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result<PathBuf> {
|
||||
let target = self.resolve_in_root(path, may_be_missing)?;
|
||||
self.check_roots(path, &target, access)?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Hold `target`, which `path` named, to the automation read or write root. Always passes
|
||||
/// without roots.
|
||||
fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> {
|
||||
match &self.roots {
|
||||
Some(roots) => roots.check_path(path, target, access).map_err(failed),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_in_root(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
let Some(root) = &self.root else { return Ok(p.to_path_buf()) };
|
||||
let outside = || failed(format!("{path} is outside the allowed directory {}", root.display()));
|
||||
@@ -1908,8 +2127,17 @@ impl Automation {
|
||||
return Err(outside());
|
||||
}
|
||||
if !rest.is_empty() && !for_write {
|
||||
// Missing, below a folder inside the root: say why, as the system reports it.
|
||||
real = joined.canonicalize().map_err(|e| failed(format!("{path}: {e}")))?;
|
||||
// Missing, below a folder inside the root: say where it was looked for, so a path
|
||||
// written for another working directory can be corrected (#1890), and otherwise
|
||||
// why, as the system reports it. `joined` is under the root, so naming it reveals
|
||||
// nothing outside.
|
||||
real = joined.canonicalize().map_err(|e| {
|
||||
let (looked, root) = (joined.display(), root.display());
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")),
|
||||
_ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")),
|
||||
}
|
||||
})?;
|
||||
if !real.starts_with(root) {
|
||||
return Err(outside()); // it appeared, as a link out, since the check above
|
||||
}
|
||||
@@ -2068,7 +2296,7 @@ impl Args<'_> {
|
||||
|
||||
/// 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]) -> Vec<Value> {
|
||||
fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec<Value> {
|
||||
// The open rows, outermost first, with their depth and the children gathered so far.
|
||||
let mut open: Vec<(usize, Value, Vec<Value>)> = Vec::new();
|
||||
let mut roots = Vec::new();
|
||||
@@ -2078,7 +2306,10 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
|
||||
close_row(&mut open, &mut roots);
|
||||
}
|
||||
let path: Vec<usize> = row.path.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
|
||||
let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
|
||||
if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) {
|
||||
fields["destination"] = destination;
|
||||
}
|
||||
open.push((depth, fields, Vec::new()));
|
||||
}
|
||||
while !open.is_empty() {
|
||||
@@ -2087,6 +2318,36 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
|
||||
roots
|
||||
}
|
||||
|
||||
/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page
|
||||
/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its
|
||||
/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the
|
||||
/// reader's position and zoom). `None` for a destination without a type.
|
||||
pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option<Value> {
|
||||
use pdfcraft_render::DestView;
|
||||
let points = |left: Option<f32>, top: Option<f32>| {
|
||||
let [x, y] = page.dest_fraction(left, top, 0);
|
||||
let round = |v: f32| f64::from((v * 100.0).round() / 100.0);
|
||||
(x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height)))
|
||||
};
|
||||
Some(match view {
|
||||
DestView::Top => return None,
|
||||
DestView::Xyz { left, top, zoom } => {
|
||||
let (x, y) = points(*left, *top);
|
||||
json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) })
|
||||
}
|
||||
DestView::Fit => json!({ "fit": "Fit" }),
|
||||
DestView::FitH { top } => {
|
||||
let (x, y) = points(None, *top);
|
||||
json!({ "fit": "FitH", "left": x, "top": y })
|
||||
}
|
||||
DestView::FitV { left } => {
|
||||
let (x, y) = points(*left, None);
|
||||
json!({ "fit": "FitV", "left": x, "top": y })
|
||||
}
|
||||
DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Closes the innermost open row: it joins the row above it as a child, or the top level.
|
||||
fn close_row(open: &mut Vec<(usize, Value, Vec<Value>)>, roots: &mut Vec<Value>) {
|
||||
if let Some((_, mut fields, children)) = open.pop() {
|
||||
@@ -2151,6 +2412,9 @@ fn info(d: &Document) -> Value {
|
||||
"pdf_version": i.pdf_version,
|
||||
"file_size": i.file_size,
|
||||
"title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords,
|
||||
// Custom document properties (Document Properties ▸ Custom): /Info entries beyond the
|
||||
// ones the spec names, set and cleared with doc_set_info.
|
||||
"custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::<Vec<_>>(),
|
||||
"creator": i.creator, "producer": i.producer,
|
||||
"tagged": i.tagged,
|
||||
"has_javascript": i.has_javascript,
|
||||
@@ -2209,6 +2473,13 @@ fn info(d: &Document) -> Value {
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).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!({
|
||||
"page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration,
|
||||
"horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque,
|
||||
"display": t.display,
|
||||
})).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
// `substitute` names the face standing in for a program the document does not embed, and
|
||||
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
|
||||
@@ -2223,9 +2494,11 @@ fn info(d: &Document) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// A user-space rectangle on 0-based `page` in displayed-page coordinates; unchanged when the
|
||||
/// page is unknown (a malformed annotation that points at no page).
|
||||
fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] {
|
||||
/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the
|
||||
/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports
|
||||
/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page).
|
||||
/// Public so `pdfcraft-cli` reports the same rectangles as the tools.
|
||||
pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] {
|
||||
i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect))
|
||||
}
|
||||
|
||||
@@ -2329,32 +2602,20 @@ fn write_atomic_synced(
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
|
||||
) -> Result<()> {
|
||||
use std::io::Write;
|
||||
// A folder can't be replaced, and staging beside it could land outside the root: "." names
|
||||
// the root itself, whose parent isn't ours to write in.
|
||||
if path.is_dir() {
|
||||
return Err(failed(format!("{}: is a folder, not a file", path.display())));
|
||||
}
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let name = path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?;
|
||||
path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?;
|
||||
std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let (tmp, file) =
|
||||
create_staging(dir, &name.to_string_lossy(), StagingName::SuffixThenTag, suffixes).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
// Closed at the end of the block, before the rename.
|
||||
let written = {
|
||||
let mut file = file;
|
||||
// Surface delayed writeback failures before replacing the destination or marking
|
||||
// a document clean. The handle is closed before rename (required on Windows).
|
||||
file.write_all(bytes).and_then(|()| sync(&file))
|
||||
};
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(failed(format!("{}: {e}", tmp.display())));
|
||||
}
|
||||
std::fs::rename(&tmp, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
failed(format!("{}: {e}", path.display()))
|
||||
})
|
||||
// The writer itself is the platform crate's, shared with the desktop app and the CLI
|
||||
// (#2166): the staging file is written, flushed (delayed writeback failures surface before
|
||||
// the destination is replaced or a document marked clean), closed and renamed; a failure at
|
||||
// any step removes it and leaves the destination as it was.
|
||||
pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync)
|
||||
.map_err(|e| failed(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2731,6 +2992,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_save_bytes_never_reach_publication_or_mark_saved() {
|
||||
let dir = staging_dir("invalid-save-bytes");
|
||||
let target = dir.join("out.pdf");
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = search_document(&mut a, 2);
|
||||
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
|
||||
let old = std::fs::read(&target).unwrap();
|
||||
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
|
||||
let before = summary(a.session.get(id).unwrap());
|
||||
// Exercise both parse failure and a valid PDF that loses a page. This does not
|
||||
// depend on a malformed input remaining unrepaired by future reader improvements.
|
||||
for bytes in [b"not a PDF".to_vec(), numbered_pages(1)] {
|
||||
let mut published = false;
|
||||
let result = a.publish_save(id, &target, Arc::new(bytes), false, |_, _| {
|
||||
published = true;
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err(), "invalid output must be refused");
|
||||
assert!(result.unwrap_err().to_string().contains("could not be reopened"));
|
||||
assert!(!published, "validation must precede the writer");
|
||||
assert_eq!(summary(a.session.get(id).unwrap()), before, "failed validation must preserve document state");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), old);
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
}
|
||||
json(&mut a, "doc_save", json!({"doc": id.0}));
|
||||
assert!(!a.session.get(id).unwrap().dirty);
|
||||
assert_ne!(std::fs::read(&target).unwrap(), old);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() {
|
||||
let dir = staging_dir("sync-dirty");
|
||||
@@ -2830,4 +3122,23 @@ mod tests {
|
||||
std::fs::set_permissions(&target, perms).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A recovered document, or a new one, rotated and then undone still has never been saved:
|
||||
/// `doc_close` refuses it unless told to discard it (#2346 review).
|
||||
#[test]
|
||||
fn closing_a_recovered_or_new_document_after_undo_is_refused() {
|
||||
let mut a = Automation::new();
|
||||
let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap();
|
||||
a.session.mark_recovered(recovered, Some("Report.pdf".into()));
|
||||
let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap();
|
||||
for id in [recovered, new] {
|
||||
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
|
||||
json(&mut a, "edit_undo", json!({"doc": id.0}));
|
||||
let refused = a.call("doc_close", &json!({"doc": id.0}));
|
||||
assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}");
|
||||
assert!(a.session.get(id).is_some(), "still open");
|
||||
json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true}));
|
||||
assert!(a.session.get(id).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,18 +65,34 @@ impl Automation {
|
||||
|
||||
pub(crate) fn link_list(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect();
|
||||
let items: Vec<Value> = doc
|
||||
.links
|
||||
.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);
|
||||
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
|
||||
match &l.action {
|
||||
LinkAction::Page(p) => v["to_page"] = json!(p + 1),
|
||||
LinkAction::Uri(u) => v["url"] = json!(u),
|
||||
LinkAction::Other(o) => v["other"] = json!(o),
|
||||
// Where an in-document link goes, as the viewer resolves it: named destinations
|
||||
// included, with the position on the page.
|
||||
let target = targets.get(&(l.page, l.index)).and_then(|t| match t {
|
||||
LinkTarget::Page(page, view) => Some((*page, view)),
|
||||
_ => None,
|
||||
});
|
||||
match (&l.action, target) {
|
||||
(LinkAction::Uri(u), _) => v["url"] = json!(u),
|
||||
(action, Some((page, view))) => {
|
||||
v["to_page"] = json!(page + 1);
|
||||
if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) {
|
||||
v["destination"] = d;
|
||||
}
|
||||
if matches!(action, LinkAction::Other(_)) {
|
||||
v["named"] = json!(true);
|
||||
}
|
||||
}
|
||||
(LinkAction::Page(p), None) => v["to_page"] = json!(p + 1),
|
||||
(LinkAction::Other(o), None) => v["other"] = json!(o),
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -163,7 +179,7 @@ impl Automation {
|
||||
let index = a.int("index")?;
|
||||
let doc = self.doc(a)?;
|
||||
let l = doc
|
||||
.links
|
||||
.links()
|
||||
.iter()
|
||||
.find(|l| l.page == page && l.index as i64 + 1 == index)
|
||||
.ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?;
|
||||
@@ -201,8 +217,8 @@ impl Automation {
|
||||
None => None,
|
||||
};
|
||||
let before = match &pages {
|
||||
Some(ps) => self.doc(a)?.links.iter().filter(|l| ps.contains(&l.page)).count(),
|
||||
None => self.doc(a)?.links.len(),
|
||||
Some(ps) => self.doc(a)?.links().iter().filter(|l| ps.contains(&l.page)).count(),
|
||||
None => self.doc(a)?.links().len(),
|
||||
};
|
||||
let mut out = self.apply(a, Edit::RemoveLinks { pages })?;
|
||||
out["removed"] = json!(before);
|
||||
|
||||
@@ -125,7 +125,18 @@ impl Automation {
|
||||
bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")))
|
||||
})?,
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content };
|
||||
// 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) {
|
||||
let dpi = a.opt_num("dpi")?.unwrap_or(300.0);
|
||||
if !(72.0..=600.0).contains(&dpi) {
|
||||
return Err(bad("dpi must be between 72 and 600"));
|
||||
}
|
||||
Some(print::Raster { dpi, grayscale: a.opt_bool("grayscale")?.unwrap_or(false) })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() };
|
||||
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
|
||||
let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len();
|
||||
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
|
||||
|
||||
@@ -173,14 +173,16 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result<Value> {
|
||||
let findings = self.doc(a)?.audit_redactions().map_err(ToolError::Failed)?;
|
||||
let d = self.doc(a)?;
|
||||
let findings = d.audit_redactions().map_err(ToolError::Failed)?;
|
||||
let items: Vec<Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"page": f.page + 1,
|
||||
"kind": f.kind.id(),
|
||||
"rect": f.rect,
|
||||
// Points from the top-left of the displayed page, like `comment_list` and `doc_info`.
|
||||
"rect": crate::view_rect(&d.info, f.page, f.rect.map(|v| v as f32)),
|
||||
"covered_text": f.covered_text,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,62 @@ fn time(secs: i64) -> sign::Time {
|
||||
sign::Time::from_unix(secs)
|
||||
}
|
||||
|
||||
/// The longest label `sign_document` takes (characters): a label is a few words.
|
||||
const MAX_LABEL_CHARS: usize = 200;
|
||||
|
||||
/// `appearance`: which items the visible signature shows; those not given keep the default
|
||||
/// (name, date and labels on; reason, location and distinguished name off).
|
||||
fn appearance_arg(v: &Value) -> Result<sign::Appearance> {
|
||||
const KEYS: &str = "name, date, reason, location, distinguished_name, labels";
|
||||
let o = v.as_object().ok_or_else(|| bad(format!("appearance must be an object of true/false items ({KEYS})")))?;
|
||||
let mut shown = sign::Appearance::default();
|
||||
for (key, value) in o {
|
||||
let on = value.as_bool().ok_or_else(|| bad(format!("appearance.{key} must be true or false")))?;
|
||||
let item = match key.as_str() {
|
||||
"name" => &mut shown.name,
|
||||
"date" => &mut shown.date,
|
||||
"reason" => &mut shown.reason,
|
||||
"location" => &mut shown.location,
|
||||
"distinguished_name" => &mut shown.distinguished_name,
|
||||
"labels" => &mut shown.labels,
|
||||
other => return Err(bad(format!("unknown appearance item {other:?} ({KEYS})"))),
|
||||
};
|
||||
*item = on;
|
||||
}
|
||||
Ok(shown)
|
||||
}
|
||||
|
||||
/// `labels`: the words of the visible signature's labels; those not given keep the English. Each
|
||||
/// must be drawable in the signature's font. Nobody sees a preview here, so a label it can't draw
|
||||
/// is refused even when this signature doesn't show it.
|
||||
fn labels_arg(v: &Value) -> Result<sign::AppearanceLabels> {
|
||||
const KEYS: &str = "signed_by, distinguished_name, reason, location, date";
|
||||
let o = v.as_object().ok_or_else(|| bad(format!("labels must be an object of strings ({KEYS})")))?;
|
||||
let mut labels = sign::AppearanceLabels::default();
|
||||
for (key, value) in o {
|
||||
let text = value.as_str().ok_or_else(|| bad(format!("labels.{key} must be a string")))?;
|
||||
if text.chars().count() > MAX_LABEL_CHARS {
|
||||
return Err(bad(format!("labels.{key} is longer than {MAX_LABEL_CHARS} characters")));
|
||||
}
|
||||
let label = match key.as_str() {
|
||||
"signed_by" => &mut labels.signed_by,
|
||||
"distinguished_name" => &mut labels.distinguished_name,
|
||||
"reason" => &mut labels.reason,
|
||||
"location" => &mut labels.location,
|
||||
"date" => &mut labels.date,
|
||||
other => return Err(bad(format!("unknown label {other:?} ({KEYS})"))),
|
||||
};
|
||||
*label = text.to_string();
|
||||
}
|
||||
if let Some((field, c)) = labels.first_undrawable() {
|
||||
return Err(bad(format!(
|
||||
"labels.{field}: {}",
|
||||
pdfcraft_engine::undrawable_message(&format!("the {field} label of the visible signature"), c)
|
||||
)));
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
pub(crate) fn cert_json(c: &Certificate) -> Value {
|
||||
json!({
|
||||
"name": c.display_name(),
|
||||
@@ -89,8 +145,8 @@ impl Automation {
|
||||
return Err(bad("name must not be empty"));
|
||||
}
|
||||
let password = a.str("password")?;
|
||||
if password.chars().count() < 6 {
|
||||
return Err(bad("the password must have at least 6 characters"));
|
||||
if password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS {
|
||||
return Err(bad(format!("the password must have at least {} characters", sign::pkcs12::MIN_PASSWORD_CHARS)));
|
||||
}
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let key = match a.opt_str("key")?.unwrap_or("rsa2048") {
|
||||
@@ -177,6 +233,17 @@ impl Automation {
|
||||
Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))),
|
||||
Some(g) => Some(self.signature_graphic(g)?),
|
||||
};
|
||||
let visible = rect.is_some() || field.is_some();
|
||||
let appearance = match a.get("appearance") {
|
||||
None | Some(Value::Null) => sign::Appearance::default(),
|
||||
Some(_) if !visible => return Err(bad("appearance needs a visible signature: pass rect or field")),
|
||||
Some(v) => appearance_arg(v)?,
|
||||
};
|
||||
let appearance_labels = match a.get("labels") {
|
||||
None | Some(Value::Null) => sign::AppearanceLabels::default(),
|
||||
Some(_) if !visible => return Err(bad("labels needs a visible signature: pass rect or field")),
|
||||
Some(v) => labels_arg(v)?,
|
||||
};
|
||||
let out = self.resolve(a.str("out")?, true)?;
|
||||
let opts = SignOptions {
|
||||
field,
|
||||
@@ -186,6 +253,8 @@ impl Automation {
|
||||
location: a.opt_str("location")?.map(str::to_string),
|
||||
contact: a.opt_str("contact")?.map(str::to_string),
|
||||
certify,
|
||||
appearance,
|
||||
appearance_labels,
|
||||
graphic,
|
||||
..SignOptions::default()
|
||||
};
|
||||
|
||||
+119
-28
@@ -133,7 +133,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.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, 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, attachments, fonts, security and repair notes.")
|
||||
.ro()
|
||||
.cmd("file.properties")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -156,12 +156,13 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
|
||||
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
|
||||
.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 (index from text_paragraphs). 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.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
@@ -171,6 +172,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
|
||||
"offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},
|
||||
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])),
|
||||
t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.")
|
||||
.ro()
|
||||
.cmd("file.word_count")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"),
|
||||
"exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" }
|
||||
}), &["doc"])),
|
||||
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.")
|
||||
.ro()
|
||||
.cmd("edit.column_select")
|
||||
@@ -217,13 +224,16 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }),
|
||||
&["doc", "pages", "to"],
|
||||
)),
|
||||
t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.")
|
||||
.cmd("page.reverse")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])),
|
||||
t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.")
|
||||
.cmd("page.insert_blank")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "at": { "type": "integer", "minimum": 1 }, "width": { "type": "number", "description": "Points." }, "height": { "type": "number", "description": "Points." } }),
|
||||
&["doc", "at"],
|
||||
)),
|
||||
t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Undoable.")
|
||||
t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Its bookmarks come along, collapsed under one bookmark named after the file, placed among the document's bookmarks where the pages went. Undoable.")
|
||||
.cmd("page.insert")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "at": { "type": "integer", "minimum": 1 }, "pages": pages("of the source file (default: all)") }), &["doc", "path", "at"])),
|
||||
t("page_extract", "Extract pages", "Copy pages into a new PDF (links, bookmarks, fields and layers that belong to them come along). separate: true writes each page as its own file into out_dir; delete: true removes the pages from this document afterwards (undoable).")
|
||||
@@ -311,7 +321,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "from", "to"],
|
||||
)),
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.")
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
@@ -364,16 +374,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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) and rotation require a single field. 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), 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.",
|
||||
)
|
||||
.with({
|
||||
let mut input = schema(
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"field": { "type": "string", "description": "The field's current name." },
|
||||
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
|
||||
"name": { "type": "string" },
|
||||
"tooltip": { "type": "string" },
|
||||
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
|
||||
"read_only": { "type": "boolean" },
|
||||
"required": { "type": "boolean" },
|
||||
"multiline": { "type": "boolean" },
|
||||
@@ -393,10 +404,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
|
||||
}),
|
||||
&["doc"],
|
||||
);
|
||||
input["oneOf"] = json!([{ "required": ["field"] }, { "required": ["fields"] }]);
|
||||
input
|
||||
}),
|
||||
)),
|
||||
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
@@ -468,7 +476,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_audit_redactions",
|
||||
"Audit faux redactions",
|
||||
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle, and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
|
||||
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle (points from the top-left of the displayed page, like comment_list), and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -479,7 +487,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 (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; 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).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -506,6 +514,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"collate": { "type": "boolean" },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] },
|
||||
"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)." },
|
||||
"options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." },
|
||||
}),
|
||||
&["doc"],
|
||||
@@ -516,7 +526,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; 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. 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(
|
||||
@@ -673,7 +683,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"country": { "type": "string", "description": "Two-letter country code." },
|
||||
"key": { "type": "string", "enum": ["rsa2048", "rsa3072", "rsa4096", "p256"] },
|
||||
"years": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||||
"password": { "type": "string", "minLength": 6 },
|
||||
"password": { "type": "string", "minLength": pdfcraft_engine::sign::pkcs12::MIN_PASSWORD_CHARS },
|
||||
"path": { "type": "string", "description": "The .p12 file to write." },
|
||||
}),
|
||||
&["name", "password", "path"],
|
||||
@@ -686,7 +696,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("sign.digital")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
.cmd("sign.digital")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -711,6 +721,31 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"minProperties": 1,
|
||||
"maxProperties": 1,
|
||||
},
|
||||
"appearance": {
|
||||
"type": "object",
|
||||
"description": "What a visible signature shows; items not given keep their default.",
|
||||
"properties": {
|
||||
"name": { "type": "boolean", "description": "Default true." },
|
||||
"date": { "type": "boolean", "description": "Default true." },
|
||||
"reason": { "type": "boolean", "description": "Default false." },
|
||||
"location": { "type": "boolean", "description": "Default false." },
|
||||
"distinguished_name": { "type": "boolean", "description": "Default false." },
|
||||
"labels": { "type": "boolean", "description": "Default true." },
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"description": "The words of a visible signature's labels (at most 200 characters each, Western European characters only); labels not given stay English.",
|
||||
"properties": {
|
||||
"signed_by": { "type": "string", "description": "Above the name. Default \"Digitally signed by\"." },
|
||||
"distinguished_name": { "type": "string", "description": "Before the certificate's subject. Default \"DN: \"." },
|
||||
"reason": { "type": "string", "description": "Default \"Reason: \"." },
|
||||
"location": { "type": "string", "description": "Default \"Location: \"." },
|
||||
"date": { "type": "string", "description": "Default \"Date: \"." },
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"out": { "type": "string", "description": "Where to save the signed document." },
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
@@ -749,13 +784,14 @@ 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, 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; \"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(
|
||||
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." },
|
||||
"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(),
|
||||
})),
|
||||
@@ -791,9 +827,29 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.")
|
||||
.cmd("page.replace")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])),
|
||||
t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.")
|
||||
t("page_duplicate", "Duplicate pages", "Insert copies of pages so the first copy becomes page `at`, or after the last of them when `at` is left out (fonts and images are shared, not copied). Undoable.")
|
||||
.cmd("page.duplicate")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])),
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to duplicate"), "at": { "type": "integer", "minimum": 1 } }), &["doc", "pages"])),
|
||||
t(
|
||||
"page_transition_set",
|
||||
"Set page transitions",
|
||||
"Set or remove the transition and display time pages play in a full-screen presentation (ISO 32000-2 §12.4.4; Acrobat's Organize ▸ Page Transitions). style: Split, Blinds, Box, Wipe, Dissolve, Glitter, R (replace, no effect), Fly, Push, Cover, Uncover or Fade, or none to remove the transition; duration in seconds (default 1); dimension horizontal/vertical (Split, Blinds); motion inward/outward (Split, Box, Fly); direction 0, 90, 180, 270, 315 or none (Wipe, Glitter, Fly, Cover, Uncover, Push); scale and opaque for Fly. display: seconds the page stays before advancing; left out, the page waits for the presenter. doc_info lists the result under transitions.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"pages": pages("to change (default: all)"),
|
||||
"style": { "type": "string", "enum": ["none", "Split", "Blinds", "Box", "Wipe", "Dissolve", "Glitter", "R", "Fly", "Push", "Cover", "Uncover", "Fade"] },
|
||||
"duration": { "type": "number", "minimum": 0 },
|
||||
"dimension": { "type": "string", "enum": ["horizontal", "vertical"] },
|
||||
"motion": { "type": "string", "enum": ["inward", "outward"] },
|
||||
"direction": { "description": "0, 90, 180, 270, 315 or \"none\"" },
|
||||
"scale": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"opaque": { "type": "boolean" },
|
||||
"display": { "type": "number", "minimum": 0 },
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t(
|
||||
"page_set_box",
|
||||
"Set page boxes / crop",
|
||||
@@ -878,6 +934,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("accessibility_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("accessibility_autotag_form_fields", "Tag form fields", "Write `/Form` tags, widget structure-parent keys, and a parent tree for all page widgets in an untagged AcroForm. Existing tag trees are refused for review. Text, figures, links, and other content are not tagged.")
|
||||
.cmd("a11y.autotag_fields")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
|
||||
.ro()
|
||||
.cmd("a11y.check")
|
||||
@@ -891,6 +950,10 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("accessibility_find_untagged", "Find untagged content", "Find untagged painted page content, annotations, and form widgets. This focused check does not modify the PDF or claim that its other accessibility rules pass.")
|
||||
.ro()
|
||||
.cmd("a11y.find_untagged")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to search (default: all)") }), &["doc"])),
|
||||
t("accessibility_report", "Accessibility report", "Run the full check and write the accessibility report (HTML) to path; returns the results too. Takes the same options as accessibility_check.")
|
||||
.destructive()
|
||||
.cmd("a11y.report")
|
||||
@@ -942,10 +1005,24 @@ 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. 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 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.")
|
||||
.ro()
|
||||
.cmd("a11y.tags")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"offset": { "type": "integer", "minimum": 0, "description": "Where the page starts, counted from 0 (the previous page's next)" },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Tags in the page (default 200)" },
|
||||
"types": { "type": "array", "items": { "type": "string" }, "description": "Only tags of these types, as listed in type (such as H1, H2, Table)" },
|
||||
"pages": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": "Only tags on these pages" },
|
||||
"max_depth": { "type": "integer", "minimum": 0, "description": "Only tags at most this deep (0 = top level)" }
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("accessibility_tag_content", "List tag content", "List the marked-content references directly owned by one reachable indirect structure tag, including page, MCID and extracted text.")
|
||||
.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.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
@@ -953,6 +1030,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] },
|
||||
"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.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
|
||||
}), &["doc", "object", "generation"])),
|
||||
t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
@@ -1021,7 +1103,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.")
|
||||
.cmd("doc.compare")
|
||||
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])),
|
||||
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
|
||||
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.")
|
||||
.cmd("form.detect")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])),
|
||||
t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).")
|
||||
@@ -1068,7 +1150,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("ocr.recognize")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")
|
||||
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")
|
||||
.cmd("export.all_images")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1164,14 +1246,14 @@ 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), 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), 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", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"path": { "type": "string" },
|
||||
@@ -1226,12 +1308,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). 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. 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.")
|
||||
.cmd("optimize.advanced")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"path": { "type": "string" },
|
||||
"preset": { "type": "string", "enum": ["standard", "mobile"] },
|
||||
"merge_fragments": { "type": "boolean" },
|
||||
"color": { "type": "object" },
|
||||
"gray": { "type": "object" },
|
||||
"discard_thumbnails": { "type": "boolean" },
|
||||
@@ -1245,9 +1329,16 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "path"],
|
||||
)),
|
||||
t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Undoable until saved.")
|
||||
t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Flattening the fields of an XFA form also takes the XFA out (its template, data and scripts), so Adobe's viewers show the baked pages rather than laying the form out again. Undoable until saved.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "comments": { "type": "boolean", "description": "Default true." }, "fields": { "type": "boolean", "description": "Default true." } }), &["doc"])),
|
||||
t(
|
||||
"xfa_to_static",
|
||||
"Convert an XFA form to a static form",
|
||||
"Make an XFA form an ordinary PDF form: the XFA packets (template, data, scripts) and PdfCraft's private keys go; the pages as laid out and the fields with their values stay, so every viewer shows the same form and none lays it out again. Its scripts no longer run. Undoable until saved.",
|
||||
)
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("command_list", "List commands", "Every registered PdfCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,376 @@
|
||||
//! `pdfcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`: through the MCP
|
||||
//! protocol, files open and load only from inside the read root and are written only inside the
|
||||
//! write root. Paths that leave a root (`..`, another folder, a sibling with the same prefix, a
|
||||
//! link) are refused with the wording VectorCraft uses, nothing is read into the result and
|
||||
//! nothing is written outside.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pdfcraft_automation::Automation;
|
||||
use pdfcraft_automation::file_access::AutomationRoots;
|
||||
use pdfcraft_automation::mcp::McpServer;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A 1x1 PNG, so the tools that take a picture have something real to read.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
/// A one-page PDF reading `text`, with a 1x1 picture on it.
|
||||
fn pdf(text: &str) -> Vec<u8> {
|
||||
let body = format!("q 100 0 0 100 20 20 cm /Im1 Do Q BT /F1 24 Tf 20 150 Td ({text}) Tj ET");
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 6 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>\nstream\n\xff\x00\x00\nendstream"
|
||||
.to_vec(),
|
||||
];
|
||||
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
|
||||
}
|
||||
|
||||
/// A fresh folder holding `in` (the read root, with `a.pdf`, `b.pdf` and `pic.png`), `out` (the
|
||||
/// write root), `outside` (with `secret.pdf` and a picture) and `in2`.
|
||||
fn folders(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-mcp-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.pdf"), pdf("Inside page")).unwrap();
|
||||
std::fs::write(base.join("in/b.pdf"), pdf("Second inside")).unwrap();
|
||||
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("outside/secret.pdf"), pdf("TOP SECRET")).unwrap();
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in2/c.pdf"), pdf("Sibling")).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
/// A server confined to `in` and `out` (either may be left out).
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (McpServer, PathBuf) {
|
||||
let base = folders(tag);
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
(McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000)), base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn rpc(server: &mut McpServer, method: &str, params: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string();
|
||||
serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `tools/call` -> (isError, all the text of the reply).
|
||||
fn call(server: &mut McpServer, name: &str, args: Value) -> (bool, String) {
|
||||
let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args}));
|
||||
let result = &reply["result"];
|
||||
let mut text =
|
||||
result["content"].as_array().map(|c| c.iter().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n")).unwrap_or_default();
|
||||
text.push_str(&result["structuredContent"].to_string());
|
||||
text.push_str(&reply["error"].to_string());
|
||||
(result["isError"] == true || reply["error"].is_object(), text)
|
||||
}
|
||||
|
||||
fn ok(server: &mut McpServer, name: &str, args: Value) -> Value {
|
||||
let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args}));
|
||||
assert!(reply["result"]["isError"] == false, "{name} {args}: {reply}");
|
||||
reply["result"]["structuredContent"].clone()
|
||||
}
|
||||
|
||||
fn refused(server: &mut McpServer, name: &str, args: Value, why: &str) {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}");
|
||||
assert!(!text.contains("TOP SECRET"), "{name} {args} let the file's text through: {text}");
|
||||
}
|
||||
|
||||
/// The text of document 1, through the resource a client can read it with.
|
||||
fn text_of(server: &mut McpServer, doc: u64) -> String {
|
||||
rpc(server, "resources/read", json!({"uri": format!("pdfcraft://doc/{doc}/text")}))["result"]["contents"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_open_only_from_inside_the_read_root() {
|
||||
let (mut server, base) = confined("open", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
assert!(text_of(&mut server, 1).contains("Inside page"));
|
||||
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}), "outside the read root");
|
||||
refused(&mut server, "doc_open", json!({"path": format!("{}{sep}..{sep}outside{sep}secret.pdf", s(&base.join("in")))}), "outside the read root");
|
||||
// A sibling whose name starts with the root's name is not inside it.
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in2/c.pdf"))}), "outside the read root");
|
||||
// The write root is not readable: the two kinds of access are separate.
|
||||
std::fs::write(base.join("out/d.pdf"), pdf("Written")).unwrap();
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("out/d.pdf"))}), "outside the read root");
|
||||
// A relative path is the working directory's (the crate's folder here): outside.
|
||||
refused(&mut server, "doc_open", json!({"path": "Cargo.toml"}), "outside the read root");
|
||||
// Nothing was opened by the refused calls.
|
||||
assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_that_read_a_path_are_held_to_the_read_root() {
|
||||
let (mut server, base) = confined("reads", true, true);
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin")));
|
||||
let out = s(&base.join("out/result.pdf"));
|
||||
for (tool, args) in [
|
||||
("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})),
|
||||
("page_replace", json!({"doc": 1, "pages": [1], "path": secret})),
|
||||
("doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), secret], "out": out})),
|
||||
("doc_create_multiple", json!({"paths": [secret], "mode": "combine", "out": out})),
|
||||
("doc_create", json!({"from": "images", "paths": [pic]})),
|
||||
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})),
|
||||
("page_add_image", json!({"doc": 1, "page": 1, "path": pic})),
|
||||
("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})),
|
||||
("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})),
|
||||
("fill_sign_add", json!({"doc": 1, "page": 1, "type": "signature", "at": [10, 10], "path": pic})),
|
||||
("form_set_image", json!({"doc": 1, "field": "f", "path": pic})),
|
||||
("doc_import_data", json!({"doc": 1, "path": secret})),
|
||||
("doc_watermark", json!({"doc": 1, "file": pic})),
|
||||
("doc_background", json!({"doc": 1, "file": pic})),
|
||||
("form_merge_data", json!({"paths": [secret], "path": out})),
|
||||
("ocr_recognize_files", json!({"paths": [secret], "folder": s(&base.join("out/ocr"))})),
|
||||
("sign_trust", json!({"paths": [secret]})),
|
||||
("sign_document", json!({"doc": 1, "id": secret, "out": out})),
|
||||
("text_font_import", json!({"doc": 1, "page": 1, "path": gone})),
|
||||
("text_font_files", json!({"directories": [s(&base.join("outside"))]})),
|
||||
] {
|
||||
refused(&mut server, tool, args, "outside the read root");
|
||||
}
|
||||
// The refused calls changed neither the document nor the disk.
|
||||
assert!(!text_of(&mut server, 1).contains("TOP SECRET"));
|
||||
assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(names(&base.join("out")), Vec::<String>::new());
|
||||
|
||||
// One file per output: a file that can't be read is reported in its own entry, with the same refusal.
|
||||
let (_, text) = call(&mut server, "doc_create_multiple", json!({"paths": [secret], "mode": "separate", "out_dir": s(&base.join("out/sep"))}));
|
||||
assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}");
|
||||
assert!(names(&base.join("out/sep")).is_empty());
|
||||
let (_, text) = call(&mut server, "action_run", json!({"paths": [secret], "folder": s(&base.join("out/act")), "action": "Add Page Numbers"}));
|
||||
assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}");
|
||||
assert!(names(&base.join("out/act")).is_empty());
|
||||
std::fs::remove_dir_all(base.join("out/act")).unwrap();
|
||||
|
||||
// The same tools work on files inside the root.
|
||||
std::fs::remove_dir_all(base.join("out/sep")).unwrap();
|
||||
ok(&mut server, "page_insert_file", json!({"doc": 1, "path": s(&base.join("in/b.pdf")), "at": 2}));
|
||||
assert!(text_of(&mut server, 1).contains("Second inside"));
|
||||
ok(&mut server, "page_add_image", json!({"doc": 1, "page": 1, "path": s(&base.join("in/pic.png"))}));
|
||||
ok(&mut server, "doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": out}));
|
||||
assert_eq!(names(&base.join("out")), ["result.pdf"]);
|
||||
// Without a folder to list, the installed fonts are outside the read root: it is searched instead.
|
||||
ok(&mut server, "text_font_files", json!({}));
|
||||
ok(&mut server, "text_font_files", json!({"directories": [s(&base.join("in"))]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_that_write_a_path_are_held_to_the_write_root() {
|
||||
let (mut server, base) = confined("writes", true, true);
|
||||
let input = |name: &str| s(&base.join("in").join(name));
|
||||
ok(&mut server, "doc_open", json!({"path": input("a.pdf")}));
|
||||
ok(&mut server, "doc_open", json!({"path": input("b.pdf")}));
|
||||
let writes = |dir: &str| -> Vec<(&'static str, Value)> {
|
||||
let file = |name: &str| s(&base.join(dir).join(name));
|
||||
vec![
|
||||
("doc_save", json!({"doc": 1, "path": file("saved.pdf")})),
|
||||
("page_extract", json!({"doc": 1, "pages": [1], "out": file("extract.pdf")})),
|
||||
("page_extract", json!({"doc": 1, "pages": [1], "separate": true, "out_dir": file("pages")})),
|
||||
("doc_combine", json!({"paths": [input("a.pdf"), input("b.pdf")], "out": file("combined.pdf")})),
|
||||
("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "combine", "out": file("multiple.pdf")})),
|
||||
("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "separate", "out_dir": file("separate")})),
|
||||
("doc_split", json!({"doc": 1, "every": 1, "out_dir": file("parts")})),
|
||||
("doc_reduce", json!({"doc": 1, "path": file("reduced.pdf")})),
|
||||
("doc_optimize", json!({"doc": 1, "path": file("optimized.pdf")})),
|
||||
("doc_print", json!({"doc": 1, "path": file("printed.pdf")})),
|
||||
("doc_export_text", json!({"doc": 1, "path": file("text.txt")})),
|
||||
("doc_export_office", json!({"doc": 1, "path": file("export.docx")})),
|
||||
("doc_export_data", json!({"doc": 1, "path": file("data.xfdf")})),
|
||||
("doc_export_images", json!({"doc": 1, "folder": file("renders")})),
|
||||
("doc_export_all_images", json!({"doc": 1, "folder": file("images")})),
|
||||
("image_save", json!({"doc": 1, "page": 1, "image": 1, "path": file("image.png")})),
|
||||
("measure_export", json!({"doc": 1, "out": file("measurements.csv")})),
|
||||
("comments_summarize", json!({"doc": 1, "out": file("summary.pdf")})),
|
||||
("accessibility_report", json!({"doc": 1, "path": file("report.html")})),
|
||||
("accessibility_review_record", json!({"before": 1, "after": 2, "path": file("record.json")})),
|
||||
("doc_compare_report", json!({"doc": 1, "other": 2, "path": file("compare.pdf")})),
|
||||
("form_merge_data", json!({"paths": [input("a.pdf")], "path": file("merged.csv")})),
|
||||
("action_run", json!({"paths": [input("a.pdf")], "folder": file("actions"), "action": "Add Page Numbers"})),
|
||||
("ocr_recognize_files", json!({"paths": [input("a.pdf")], "folder": file("ocr")})),
|
||||
("sign_id_create", json!({"name": "Test", "password": "secret12", "path": file("id.p12"), "key": "p256"})),
|
||||
]
|
||||
};
|
||||
|
||||
for (tool, args) in writes("outside") {
|
||||
refused(&mut server, tool, args, "outside the write root");
|
||||
}
|
||||
// The read root is not writable either.
|
||||
for (tool, args) in writes("in") {
|
||||
refused(&mut server, tool, args, "outside the write root");
|
||||
}
|
||||
// `..` out of the write root, and a sibling that shares its prefix.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
refused(
|
||||
&mut server,
|
||||
"doc_save",
|
||||
json!({"doc": 1, "path": format!("{}{sep}..{sep}outside{sep}up.pdf", s(&base.join("out")))}),
|
||||
"outside the write root",
|
||||
);
|
||||
std::fs::create_dir_all(base.join("out2")).unwrap();
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out2/x.pdf"))}), "outside the write root");
|
||||
// Saving in place writes to the folder the file was opened from: the read root, not the write root.
|
||||
refused(&mut server, "doc_save", json!({"doc": 1}), "outside the write root");
|
||||
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"], "nothing was written outside the roots");
|
||||
assert_eq!(names(&base.join("in")), ["a.pdf", "b.pdf", "pic.png"], "nothing was written to the read root");
|
||||
assert!(names(&base.join("out2")).is_empty());
|
||||
assert!(names(&base.join("out")).is_empty());
|
||||
|
||||
// The same calls land in the write root.
|
||||
for (tool, args) in writes("out") {
|
||||
let (error, text) = call(&mut server, tool, args.clone());
|
||||
// (Printing to a PDF and the like may not apply to this fixture: a refusal for the roots is the failure.)
|
||||
assert!(!text.contains("automation path rejected") && !text.contains("automation filesystem access"), "{tool} {args}: {text}");
|
||||
let _ = error;
|
||||
}
|
||||
assert!(base.join("out/saved.pdf").is_file() && base.join("out/combined.pdf").is_file() && base.join("out/text.txt").is_file());
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_writes_and_reads_only_inside_the_roots() {
|
||||
// One folder for both roots, so a digital ID can be made and then read back.
|
||||
let base = folders("signing");
|
||||
let work = base.join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::write(work.join("a.pdf"), pdf("Work page")).unwrap();
|
||||
let roots = AutomationRoots::new(Some(&work), Some(&work)).unwrap();
|
||||
let mut server = McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000));
|
||||
ok(&mut server, "doc_open", json!({"path": s(&work.join("a.pdf"))}));
|
||||
ok(&mut server, "sign_id_create", json!({"name": "Test", "password": "secret12", "path": s(&work.join("id.p12")), "key": "p256"}));
|
||||
refused(
|
||||
&mut server,
|
||||
"sign_id_create",
|
||||
json!({"name": "Test", "password": "secret12", "path": s(&base.join("outside/id.p12")), "key": "p256"}),
|
||||
"outside the write root",
|
||||
);
|
||||
refused(
|
||||
&mut server,
|
||||
"sign_document",
|
||||
json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&base.join("outside/signed.pdf"))}),
|
||||
"outside the write root",
|
||||
);
|
||||
ok(&mut server, "sign_document", json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&work.join("signed.pdf"))}));
|
||||
assert!(work.join("signed.pdf").is_file());
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let (mut server, base) = confined("read-only", true, false);
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
for target in ["out/doc.pdf", "in/doc.pdf"] {
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join(target))}), "write authority is absent");
|
||||
}
|
||||
refused(&mut server, "doc_save", json!({"doc": 1}), "write authority is absent");
|
||||
refused(&mut server, "doc_export_text", json!({"doc": 1, "path": s(&base.join("out/t.txt"))}), "write authority is absent");
|
||||
refused(&mut server, "page_extract", json!({"doc": 1, "pages": [1], "out": s(&base.join("out/e.pdf"))}), "write authority is absent");
|
||||
refused(&mut server, "doc_split", json!({"doc": 1, "every": 1, "out_dir": s(&base.join("out/parts"))}), "write authority is absent");
|
||||
assert!(names(&base.join("out")).is_empty() && names(&base.join("in")) == ["a.pdf", "b.pdf", "pic.png"]);
|
||||
// A tool that gives its result back instead of writing it still can.
|
||||
ok(&mut server, "page_extract", json!({"doc": 1, "pages": [1]}));
|
||||
|
||||
let (mut server, base) = confined("write-only", false, true);
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}), "read authority is absent");
|
||||
refused(
|
||||
&mut server,
|
||||
"doc_combine",
|
||||
json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": s(&base.join("out/c.pdf"))}),
|
||||
"read authority is absent",
|
||||
);
|
||||
refused(&mut server, "text_font_files", json!({}), "read authority is absent");
|
||||
ok(&mut server, "doc_create", json!({"from": "blank", "pages": 1, "width": 200, "height": 300}));
|
||||
ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/blank.pdf"))}));
|
||||
assert!(base.join("out/blank.pdf").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_out_of_a_root_are_refused() {
|
||||
let (mut server, base) = confined("links", true, true);
|
||||
#[cfg(unix)]
|
||||
let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok()
|
||||
&& std::os::unix::fs::symlink(base.join("outside/secret.pdf"), base.join("in/leak.pdf")).is_ok()
|
||||
&& std::os::unix::fs::symlink(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).is_ok();
|
||||
#[cfg(windows)]
|
||||
let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok()
|
||||
&& std::os::windows::fs::symlink_file(base.join("outside/secret.pdf"), base.join("in/leak.pdf")).is_ok()
|
||||
&& std::os::windows::fs::symlink_file(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).is_ok();
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let linked = false;
|
||||
// Windows needs Developer Mode or elevation to make links.
|
||||
if !linked {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
return;
|
||||
}
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in/leak.pdf"))}), "outside the read root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/doc.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/deeper/doc.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/dangling.pdf"))}), "a link that leads nowhere");
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let base = folders("none");
|
||||
let mut server = McpServer::new(Automation::new());
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}));
|
||||
assert!(text_of(&mut server, 1).contains("TOP SECRET"));
|
||||
ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("in2/copy.pdf"))}));
|
||||
assert!(base.join("in2/copy.pdf").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_roots_apply_on_top_of_a_root() {
|
||||
let base = folders("both");
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap();
|
||||
// `--root` resolves relative paths inside `in`, which the read root allows and the write root does not.
|
||||
let mut server = McpServer::new(Automation::new().with_root(base.join("in")).unwrap().with_automation_roots(roots));
|
||||
ok(&mut server, "doc_open", json!({"path": "a.pdf"}));
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": "copy.pdf"}), "outside the write root");
|
||||
refused(&mut server, "doc_open", json!({"path": "../outside/secret.pdf"}), "outside the allowed directory");
|
||||
assert!(!base.join("in/copy.pdf").exists());
|
||||
// The write root alone is not enough: the other root still has its say.
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": "../out/copy.pdf"}), "outside the allowed directory");
|
||||
}
|
||||
@@ -11,7 +11,17 @@ fn result(auto: &mut Automation, args: Value) -> Value {
|
||||
|
||||
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
|
||||
fn pdf() -> Vec<u8> {
|
||||
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
|
||||
// Reuse one synthetic document so byte comparisons cannot straddle the
|
||||
// writer's modification-time clock. Each caller gets an independent buffer.
|
||||
static PDF: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
PDF.get_or_init(|| Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_fixture_remains_identical_across_metadata_clock_tick() {
|
||||
let before = pdf();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
assert_eq!(before, pdf());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Editing a button's visible label through the CLI/MCP tool table (#2892).
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use pdfcraft_engine::{DocId, Edit, NewField, Session};
|
||||
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 button_caption_can_be_changed_after_creation() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddField {
|
||||
page: 0,
|
||||
rect: [40.0, 300.0, 200.0, 330.0],
|
||||
kind: NewField::Button { caption: String::new() },
|
||||
name: Some("clear_button".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
|
||||
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = a.session().save_bytes(DocId(id)).unwrap();
|
||||
let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
|
||||
assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
|
||||
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
|
||||
let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
|
||||
tool(&mut a, "edit_undo", json!({"doc": id}));
|
||||
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
|
||||
tool(&mut a, "edit_redo", json!({"doc": id}));
|
||||
tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"}));
|
||||
let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap());
|
||||
let doc = pdfcraft_cos::Document::open(saved).unwrap();
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
|
||||
let button = doc.resolve(&fields.as_array().unwrap()[0]);
|
||||
let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap());
|
||||
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
|
||||
assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button");
|
||||
let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap();
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": reopened}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
|
||||
let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap();
|
||||
assert_eq!(pixels, labelled_pixels);
|
||||
let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"}));
|
||||
assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0")));
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported");
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
|
||||
for invalid in [
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": 42}),
|
||||
json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}),
|
||||
json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}),
|
||||
] {
|
||||
let before = a.session().save_bytes(DocId(id)).unwrap();
|
||||
assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}");
|
||||
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}");
|
||||
}
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true}));
|
||||
let before = a.session().save_bytes(DocId(id)).unwrap();
|
||||
assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err());
|
||||
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false}));
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""}));
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "");
|
||||
let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_eq!(pixels, empty_pixels);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -12,3 +12,8 @@ let m = Matrix::from_operands(&op.operands); // cm / Tm arithmetic, invert, bbo
|
||||
The parser is tolerant (unreadable bytes are skipped and counted, never fatal) and never
|
||||
panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker
|
||||
and the `Patch` API of architecture §6 grow here.
|
||||
|
||||
Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their
|
||||
sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes
|
||||
that look like the terminator. Filtered images and resource-defined colour spaces retain
|
||||
the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too.
|
||||
+139
-6
@@ -60,6 +60,10 @@ pub struct Parsed {
|
||||
pub ops: Vec<Op>,
|
||||
/// Bytes that could not be read as operands or operators.
|
||||
pub skipped: usize,
|
||||
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
|
||||
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
|
||||
/// depends on the number of pieces, not of comments.
|
||||
comment_edges: Vec<(bool, bool)>,
|
||||
}
|
||||
|
||||
fn is_ws(b: u8) -> bool {
|
||||
@@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool {
|
||||
|
||||
/// Parse a (decoded) content stream.
|
||||
pub fn parse(data: &[u8]) -> Parsed {
|
||||
let mut out = Parsed::default();
|
||||
parse_pieces(data, &[])
|
||||
}
|
||||
|
||||
/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment
|
||||
/// crosses.
|
||||
fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
|
||||
let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() };
|
||||
let mut boundary = 0;
|
||||
let mut lx = Lexer::new(data, 0);
|
||||
let mut operands: Vec<Object> = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
loop {
|
||||
let trivia = lx.pos;
|
||||
lx.skip_ws();
|
||||
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
|
||||
// string, a name, an array or dictionary operand or inline image data is not one.
|
||||
if boundary < ends.len() {
|
||||
comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges);
|
||||
}
|
||||
let Some(&b) = data.get(lx.pos) else { break };
|
||||
let at = lx.pos;
|
||||
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
@@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed {
|
||||
out
|
||||
}
|
||||
|
||||
/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and
|
||||
/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward.
|
||||
fn comment_edges(data: &[u8], trivia: std::ops::Range<usize>, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) {
|
||||
let mut at = trivia.start;
|
||||
while at < trivia.end {
|
||||
if data.get(at) != Some(&b'%') {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let comment = at;
|
||||
while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) {
|
||||
at += 1;
|
||||
}
|
||||
// The separator of piece `boundary` is the last byte before its end.
|
||||
while let Some(&end) = ends.get(*boundary) {
|
||||
let separator = end.saturating_sub(1);
|
||||
if separator >= at {
|
||||
break;
|
||||
}
|
||||
if separator >= comment {
|
||||
if let Some(edge) = edges.get_mut(*boundary) {
|
||||
edge.1 = true;
|
||||
}
|
||||
// Includes a comment whose end of line is the next piece's first byte.
|
||||
if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) {
|
||||
edge.0 = true;
|
||||
}
|
||||
}
|
||||
*boundary += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
|
||||
/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
|
||||
fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize> {
|
||||
@@ -175,12 +225,83 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize
|
||||
}
|
||||
// One whitespace byte separates ID from the data.
|
||||
let begin = (lx.pos + 1).min(data.len());
|
||||
Ok(match end_of_image(data, begin) {
|
||||
let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin));
|
||||
Ok(match end {
|
||||
Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
|
||||
None => (dict, data[begin..].to_vec(), data.len()),
|
||||
})
|
||||
}
|
||||
|
||||
/// An unfiltered image's samples determine its length, including byte padding per row.
|
||||
/// Binary samples may themselves contain whitespace + `EI` + whitespace, so use the known
|
||||
/// length when the actual terminator follows it. Unknown or damaged layouts keep the heuristic.
|
||||
fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize, usize)> {
|
||||
for key in [b"Filter".as_slice(), b"F"] {
|
||||
if let Some(filter) = dict.get(key)
|
||||
&& !matches!(filter, Object::Null)
|
||||
&& !matches!(filter, Object::Array(filters) if filters.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for (full, short) in
|
||||
[(b"Width".as_slice(), b"W".as_slice()), (b"Height", b"H"), (b"ColorSpace", b"CS"), (b"BitsPerComponent", b"BPC"), (b"ImageMask", b"IM")]
|
||||
{
|
||||
if let (Some(full), Some(short)) = (dict.get(full), dict.get(short))
|
||||
&& full != short
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let get = |full: &[u8], short: &[u8]| dict.get(full).or_else(|| dict.get(short));
|
||||
let width = usize::try_from(get(b"Width", b"W")?.as_int()?).ok().filter(|n| *n > 0)?;
|
||||
let height = usize::try_from(get(b"Height", b"H")?.as_int()?).ok().filter(|n| *n > 0)?;
|
||||
let mask = match get(b"ImageMask", b"IM") {
|
||||
None | Some(Object::Null | Object::Bool(false)) => false,
|
||||
Some(Object::Bool(true)) => true,
|
||||
_ => return None,
|
||||
};
|
||||
let bits = match get(b"BitsPerComponent", b"BPC") {
|
||||
None | Some(Object::Null) => None,
|
||||
Some(value) => Some(value.as_int()?),
|
||||
};
|
||||
let (components, bits) = if mask {
|
||||
if bits.is_some_and(|bits| bits != 1) {
|
||||
return None;
|
||||
}
|
||||
(1usize, 1usize)
|
||||
} else {
|
||||
let (components, max_bits) = match get(b"ColorSpace", b"CS")? {
|
||||
Object::Name(name) => match name.as_slice() {
|
||||
b"DeviceGray" | b"G" => (1, 16),
|
||||
b"DeviceRGB" | b"RGB" => (3, 16),
|
||||
b"DeviceCMYK" | b"CMYK" => (4, 16),
|
||||
_ => return None,
|
||||
},
|
||||
Object::Array(space) if space.len() == 4 && matches!(space.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => (1, 8),
|
||||
_ => return None,
|
||||
};
|
||||
let bits = match bits? {
|
||||
bits @ (1 | 2 | 4 | 8 | 16) => bits as usize,
|
||||
_ => return None,
|
||||
};
|
||||
if bits > max_bits {
|
||||
return None;
|
||||
}
|
||||
(components, bits)
|
||||
};
|
||||
let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8);
|
||||
let end_data = begin.checked_add(row.checked_mul(height)?)?;
|
||||
let after = data.get(end_data..)?;
|
||||
if !after.first().is_some_and(|b| is_ws(*b)) {
|
||||
return None;
|
||||
}
|
||||
let separator = after.iter().position(|b| !is_ws(*b))?;
|
||||
let ei = end_data.checked_add(separator)?;
|
||||
let end = ei.checked_add(2)?;
|
||||
if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None }
|
||||
}
|
||||
|
||||
/// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The
|
||||
/// data ends at whitespace + `EI` + (whitespace, a delimiter or the end).
|
||||
fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> {
|
||||
@@ -234,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec<u8> {
|
||||
/// A page's content streams joined into the one stream they are. Writers may split it between
|
||||
/// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its
|
||||
/// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back
|
||||
/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together.
|
||||
/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro,
|
||||
/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece
|
||||
/// runs on into the next one, up to its first end of line.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Pieces {
|
||||
data: Vec<u8>,
|
||||
@@ -248,7 +371,7 @@ impl Pieces {
|
||||
let mut ends = Vec::with_capacity(pieces.len());
|
||||
for p in pieces {
|
||||
data.extend_from_slice(p.as_ref());
|
||||
data.push(b'\n');
|
||||
data.push(b' ');
|
||||
ends.push(data.len());
|
||||
}
|
||||
Pieces { data, ends }
|
||||
@@ -270,12 +393,22 @@ impl Pieces {
|
||||
|
||||
/// Parse the pieces as one stream.
|
||||
pub fn parse(&self) -> Parsed {
|
||||
parse(&self.data)
|
||||
parse_pieces(&self.data, &self.ends)
|
||||
}
|
||||
|
||||
/// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs
|
||||
/// into it from the piece before, and whether one runs out of it into the next. A writer
|
||||
/// that rewrites a piece without its comments must keep both: end the incoming comment
|
||||
/// before its first operator, and leave a comment open at its end.
|
||||
pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> {
|
||||
let mut edges = parsed.comment_edges.clone();
|
||||
edges.resize(self.len(), (false, false));
|
||||
edges
|
||||
}
|
||||
|
||||
/// The piece holding joined position `at` (the last piece for positions past the end).
|
||||
pub fn piece_of(&self, at: usize) -> usize {
|
||||
self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1))
|
||||
self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The pieces an operator spans: (the one its first token is in, the one its keyword is in).
|
||||
|
||||
@@ -38,6 +38,171 @@ fn inline_images_stay_whole() {
|
||||
assert_eq!(again.ops[2].inline, p.ops[2].inline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_keeps_whitespace_bounded_ei_pixels() {
|
||||
let src = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q";
|
||||
let parsed = parse(src);
|
||||
assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"cm", b"BI", b"Q"]);
|
||||
let rewritten = serialize_ops(&parsed.ops);
|
||||
assert_eq!(parse(&rewritten).ops[2].inline, parsed.ops[2].inline);
|
||||
}
|
||||
|
||||
fn inline_pixels(parameters: &[u8], pixels: &[u8]) -> Parsed {
|
||||
let mut src = b"q BI ".to_vec();
|
||||
src.extend_from_slice(parameters);
|
||||
src.extend_from_slice(b" ID ");
|
||||
src.extend_from_slice(pixels);
|
||||
src.extend_from_slice(b"\nEI Q");
|
||||
parse(&src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_lengths_use_components_and_parameter_aliases() {
|
||||
for (parameters, pixels) in [
|
||||
(b"/W 4 /H 1 /CS /G /BPC 8".as_slice(), b" EI ".as_slice()),
|
||||
(b"/W 4 /H 1 /CS /G /BPC 8 /Filter null", b" EI "),
|
||||
(b"/W 4 /H 1 /CS /G /BPC 8 /F []", b" EI "),
|
||||
(b"/W 4 /Width 4 /H 1 /CS /G /BPC 8", b" EI "),
|
||||
(b"/Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b" EI "),
|
||||
(b"/W 2 /H 1 /CS /RGB /BPC 8", b"\0 EI \xff"),
|
||||
(b"/Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", b"\0 EI \xff"),
|
||||
(b"/W 1 /H 1 /CS /CMYK /BPC 8", b" EI "),
|
||||
(b"/Width 1 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", b" EI "),
|
||||
] {
|
||||
let parsed = inline_pixels(parameters, pixels);
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"BI", b"Q"]);
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, pixels);
|
||||
assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[1].inline, parsed.ops[1].inline);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_rows_are_byte_aligned_for_every_sample_depth() {
|
||||
// Two rows of two bytes each. Padding belongs to each row, not only the final row.
|
||||
for (bits, width) in [(1, 9), (2, 5), (4, 3), (8, 2), (16, 1)] {
|
||||
let parameters = format!("/W {width} /H 2 /BPC {bits} /CS /G");
|
||||
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ", "bits={bits}");
|
||||
assert_eq!(parsed.ops.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_component() {
|
||||
for parameters in [b"/W 9 /H 2 /IM true".as_slice(), b"/Width 9 /Height 2 /ImageMask true /BitsPerComponent 1"] {
|
||||
let parsed = inline_pixels(parameters, b" EI ");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(parsed.ops.len(), 3);
|
||||
}
|
||||
for name in ["Indexed", "I"] {
|
||||
// A valid 256-entry RGB lookup table; samples are indices regardless of the base space.
|
||||
let parameters = format!("/W 4 /H 1 /BPC 8 /CS [/{name} /DeviceRGB 255 <{}>]", "00".repeat(768));
|
||||
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(parsed.ops.len(), 3);
|
||||
// Indexed samples permit at most 8 bits, even though device spaces permit 16.
|
||||
let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16");
|
||||
let parsed = inline_pixels(invalid.as_bytes(), b" EI ");
|
||||
assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_terminator_must_follow_its_samples() {
|
||||
for separator in [b" ".as_slice(), b"\r\n", b"\0\t\x0c\n"] {
|
||||
let mut src = b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI ".to_vec();
|
||||
src.extend_from_slice(separator);
|
||||
src.extend_from_slice(b"EI/Q q");
|
||||
let parsed = parse(&src);
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(&src[parsed.ops[0].span.clone()], &src[..src.len() - b"/Q q".len()]);
|
||||
}
|
||||
let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI");
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI ");
|
||||
// EI is a keyword: a prefix of a larger token is not a terminator.
|
||||
let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abcd EIx\nEI Q");
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abcd EIx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
|
||||
for parameters in [
|
||||
b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(),
|
||||
b"/W 4 /H 1 /BPC 8 /CS /ResourceColor",
|
||||
b"/W 0 /H 1 /BPC 8 /CS /G",
|
||||
b"/W -1 /H 1 /BPC 8 /CS /G",
|
||||
b"/W 4 /H -1 /BPC 8 /CS /G",
|
||||
b"/W 4 /H 1 /BPC 3 /CS /G",
|
||||
b"/W 4 /H 1 /IM true /BPC 8",
|
||||
b"/W 4 /H 1 /IM true /BPC /Bad",
|
||||
b"/Width 4 /W 1 /H 1 /BPC 8 /CS /G",
|
||||
b"/W 4 /H 1 /BPC 8 /CS /G /ImageMask false /IM true",
|
||||
b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK",
|
||||
b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G",
|
||||
] {
|
||||
let parsed = inline_pixels(parameters, b"! EI !!~>");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters));
|
||||
}
|
||||
// A short payload still uses the tolerant recovery, including when there is no EI.
|
||||
for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] {
|
||||
assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc");
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_pdf(content: &[u8], object_streams: bool) -> Vec<u8> {
|
||||
use pdfcraft_cos::{Document, SaveOptions, Stream, write_full};
|
||||
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
|
||||
let page = doc.add(Object::Dict(
|
||||
[
|
||||
(b"Type".to_vec(), Object::name("Page")),
|
||||
(b"Parent".to_vec(), Object::Ref(pages)),
|
||||
(b"MediaBox".to_vec(), Object::Array([0, 0, 4, 1].map(Object::Int).to_vec())),
|
||||
(b"Resources".to_vec(), Object::Dict(Dict::new())),
|
||||
(b"Contents".to_vec(), Object::Ref(contents)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
));
|
||||
doc.update_dict(pages, |dict| {
|
||||
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
dict.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewritten_inline_image_pixels_survive_saving_and_reopening_a_pdf() {
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
use std::sync::Arc;
|
||||
|
||||
let original = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q";
|
||||
let rewritten = serialize_ops(&parse(original).ops);
|
||||
for object_streams in [false, true] {
|
||||
let bytes = inline_pdf(&rewritten, object_streams);
|
||||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||||
assert!(doc.repair_log().is_empty());
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let pages = doc.get(pages);
|
||||
let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]);
|
||||
let content = doc.resolve(page.as_dict().unwrap().get(b"Contents").unwrap());
|
||||
let Object::Stream(stream) = &*content else { panic!("page content stream") };
|
||||
assert_eq!(parse(&stream.decoded_strict().unwrap()).ops[2].inline.as_ref().unwrap().1, b" EI ");
|
||||
// Opt-in artifacts for independent readers/renderers; synthetic PDFs are never committed.
|
||||
if let Some(dir) = std::env::var_os("PDFCRAFT_INLINE_FIXTURE_DIR") {
|
||||
let dir = std::path::PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("original.pdf"), inline_pdf(original, false)).unwrap();
|
||||
std::fs::write(dir.join(if object_streams { "rewritten-packed.pdf" } else { "rewritten-classic.pdf" }), bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_is_skipped_not_fatal() {
|
||||
let p = parse(b"1 0 0 RG ) ] 10 20 m 30 40 l S (unterminated");
|
||||
@@ -118,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() {
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_boundary_comment_matches_space_concatenation() {
|
||||
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
||||
let mut tail = b"(HIDDEN) Tj".to_vec();
|
||||
tail.extend(eol);
|
||||
tail.extend(b"(LIVE) Tj");
|
||||
let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]);
|
||||
let parsed = pieces.parse();
|
||||
let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect();
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_terminate_at_next_piece_byte_zero() {
|
||||
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
||||
let mut next = eol.to_vec();
|
||||
next.extend_from_slice(b"q Q");
|
||||
let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}");
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_span_empty_pieces_and_end_of_content() {
|
||||
let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
||||
|
||||
let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]);
|
||||
assert!(parsed.ops.is_empty());
|
||||
|
||||
let pieces = Pieces::join::<&[u8]>(&[]);
|
||||
assert!(pieces.comment_edges(&pieces.parse()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_ignore_percent_inside_operands_and_image_data() {
|
||||
for first in [
|
||||
b"(literal % not a comment) Tj".as_slice(),
|
||||
b"/Percent#25Name MP",
|
||||
b"[(literal % not a comment)] TJ",
|
||||
b"/P << /ActualText (literal % not a comment) >> BDC EMC",
|
||||
b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI",
|
||||
] {
|
||||
let pieces = Pieces::join(&[first, b"q Q"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}");
|
||||
assert_eq!(parsed.ops.last().unwrap().op, b"Q");
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_operand_comment_split_is_owned_by_spanning_operator() {
|
||||
for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] {
|
||||
let pieces = Pieces::join(&parts);
|
||||
let parsed = pieces.parse();
|
||||
// The object lexer consumes this nested comment. Its containing operator spans
|
||||
// both pieces, which forces both to be rewritten together by the redactor.
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]);
|
||||
assert_eq!(parsed.ops.len(), 1);
|
||||
assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1));
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() {
|
||||
let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edge_storage_depends_on_piece_count_not_comment_count() {
|
||||
let mut first = b"%\n".repeat(16_384);
|
||||
first.extend_from_slice(b"q % crossing");
|
||||
let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]);
|
||||
assert_eq!(parsed.ops.len(), 2);
|
||||
// Also exercise public parsing without piece-boundary metadata.
|
||||
assert_eq!(parse(&first).ops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() {
|
||||
// Joined layout: q<space><space>Q<space>; separator belongs to prior piece.
|
||||
let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]);
|
||||
for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] {
|
||||
assert_eq!(pieces.piece_of(at), expected, "position {at}");
|
||||
}
|
||||
let empty = Pieces::join::<&[u8]>(&[]);
|
||||
assert_eq!(empty.piece_of(0), 0);
|
||||
assert_eq!(empty.piece_of(usize::MAX), 0);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pdfcraft-filters = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-oracle = { workspace = true }
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
hayro-syntax = { workspace = true }
|
||||
@@ -53,7 +53,7 @@ another. Edits never touch the original bytes.
|
||||
headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged.
|
||||
- **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing
|
||||
for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields
|
||||
`CosError`, never a panic — this crate is the first thing a hostile file meets.
|
||||
`CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects.
|
||||
- **Lazy.** Objects are parsed on first use and cached. (Whole-file buffering is still the case;
|
||||
`ByteSource` streaming is `core.lazy-loading`, not yet done.)
|
||||
- **Encryption** is handled on load and save through `pdfcraft-crypt`: every standard-security
|
||||
|
||||
+1180
-62
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,7 @@ mod security;
|
||||
mod writer;
|
||||
|
||||
pub use bytes::Bytes;
|
||||
pub use document::{Document, Revision, SourceIdentity, XrefEntry};
|
||||
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
|
||||
pub use parser::{Lexer, is_whitespace, parse_indirect};
|
||||
pub use pdfcraft_crypt::{
|
||||
|
||||
@@ -482,6 +482,19 @@ mod tests {
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_flate_fallback_streams_preserve_the_caller_limit() {
|
||||
// Raw deflate with a stored byte and a final empty block. 78 01 also
|
||||
// passes the zlib header check, so decoding must retry the whole input.
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let stream = Stream::from_raw(dict, vec![0x78, 0x01, 0x00, 0xfe, 0xff, b'A', 0x01, 0x00, 0x00, 0xff, 0xff]);
|
||||
let result = stream.decoded_within(0);
|
||||
assert!(matches!(&result, Err(CosError::Filter(detail)) if detail.contains("exceeds the limit of 0 bytes")), "{result:?}");
|
||||
assert_eq!(stream.decoded_within(1).unwrap(), b"A");
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_filter_decodes_only_the_outer_wrapper() {
|
||||
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
|
||||
|
||||
@@ -337,11 +337,14 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
return Ok(Object::Real(0.0));
|
||||
}
|
||||
match s.trim_start_matches('+').parse::<i64>() {
|
||||
// Tolerate a doubled sign ("--280") like the real branch does.
|
||||
let t = s.trim_start_matches('+');
|
||||
let cleaned = t.strip_prefix('-').filter(|r| r.starts_with('-')).unwrap_or(t);
|
||||
match cleaned.parse::<i64>() {
|
||||
Ok(v) => Ok(Object::Int(v)),
|
||||
Err(_) => {
|
||||
// Out-of-range integers are treated as reals (§7.3.3 allows implementation limits).
|
||||
s.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
|
||||
cleaned.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,6 +478,20 @@ mod tests {
|
||||
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_sign_integer_reads_like_the_real_form() {
|
||||
// Some writers emit a TJ kerning as "--280"; the real form "--280.0" is already read.
|
||||
assert_eq!(obj("--280.0"), Object::Real(-280.0));
|
||||
assert_eq!(obj("--280"), Object::Int(-280));
|
||||
match obj("[(Hello) --280 (World)]") {
|
||||
Object::Array(items) => {
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[1], Object::Int(-280));
|
||||
}
|
||||
other => panic!("not an array: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalars() {
|
||||
assert_eq!(obj("true"), Object::Bool(true));
|
||||
|
||||
@@ -34,12 +34,14 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
|
||||
}
|
||||
let v = int(b"V", 0);
|
||||
// V4: the key length comes from the standard crypt filter's /Length (bytes, or bits in
|
||||
// some files); /Length on the dictionary is often a stale 40.
|
||||
// some files); /Length on the dictionary is often a stale 40. A length that isn't
|
||||
// positive means nothing and reads as if absent.
|
||||
let cf_len = d
|
||||
.get(b"CF")
|
||||
.map(|c| doc.resolve(c))
|
||||
.and_then(|c| c.as_dict().and_then(|c| c.get(b"StdCF").map(|f| doc.resolve(f))))
|
||||
.and_then(|f| f.as_dict().and_then(|f| f.int(b"Length")));
|
||||
.and_then(|f| f.as_dict().and_then(|f| f.int(b"Length")))
|
||||
.filter(|n| *n > 0);
|
||||
let length_bits = match (v, cf_len) {
|
||||
(5.., _) => 256,
|
||||
(4, Some(n)) if n <= 32 => n * 8,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are
|
||||
//! never modified, which keeps digital signatures over earlier revisions valid.
|
||||
//! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a
|
||||
//! cross-reference table or stream — the "Save As" / garbage-collecting path.
|
||||
//! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer
|
||||
//! entries are copied, including objects reachable only from them.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
@@ -182,7 +183,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
}
|
||||
// Reconstructed files have no chain to append to; added or removed encryption must
|
||||
// rewrite every object; redaction must not leave the old revision behind. All need a full save.
|
||||
if doc.revisions().is_empty() || doc.encryption_changed() || doc.full_save_required() {
|
||||
if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() {
|
||||
return write_full(doc, opts);
|
||||
}
|
||||
let mut doc = doc.clone();
|
||||
@@ -192,7 +193,11 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
// allocated once at its exact size: growing a buffer that holds a large original past a
|
||||
// guessed capacity would copy the whole file again and keep up to as much again unused.
|
||||
let newline = !matches!(original.last(), Some(b'\n' | b'\r'));
|
||||
let base = (original.len() + usize::from(newline)) as u64;
|
||||
// A file with bytes before its `%PDF-` header may count its offsets from the header. The new
|
||||
// section then has to as well: readers take `/Prev` and `startxref` in one convention, and a
|
||||
// mix sends them into reconstruction (#2364).
|
||||
let shift = if doc.revisions().last().is_some_and(|r| r.header_relative) { doc.header_offset() } else { 0 };
|
||||
let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64;
|
||||
let mut out = Vec::new();
|
||||
let mut offsets: BTreeMap<u32, (u64, u16, bool)> = BTreeMap::new(); // num → (offset, gen, in use)
|
||||
for (num, generation, obj) in doc.overlay_entries() {
|
||||
@@ -257,6 +262,13 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
queue.push_back(r);
|
||||
}
|
||||
}
|
||||
// Unknown trailer entries are document data. Objects they alone reference must be written
|
||||
// too, or a full rewrite would keep the key and drop its value.
|
||||
for (key, value) in trailer_in.iter() {
|
||||
if !trailer_key_rewritten(key) {
|
||||
collect_refs(value, &mut |r| queue.push_back(r));
|
||||
}
|
||||
}
|
||||
while let Some(r) = queue.pop_front() {
|
||||
if map.contains_key(&r) {
|
||||
continue;
|
||||
@@ -337,6 +349,11 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
if let Some(id) = trailer_in.get(b"ID") {
|
||||
trailer.set(b"ID".to_vec(), id.clone());
|
||||
}
|
||||
for (key, value) in trailer_in.iter() {
|
||||
if !trailer_key_rewritten(key) {
|
||||
trailer.set(key.clone(), renumber(value, &map));
|
||||
}
|
||||
}
|
||||
if reader.document.output_security().0.is_none() {
|
||||
ensure_id(&mut trailer, opts, reader.document.bytes());
|
||||
}
|
||||
@@ -400,6 +417,15 @@ impl ObjectStreamBatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trailer keys a full rewrite generates itself. Everything else is copied, with references
|
||||
/// renumbered, so a save does not drop metadata the reader accepted.
|
||||
fn trailer_key_rewritten(key: &[u8]) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
b"Size" | b"Prev" | b"XRefStm" | b"Type" | b"W" | b"Index" | b"Filter" | b"DecodeParms" | b"Length" | b"Root" | b"Info" | b"Encrypt" | b"ID"
|
||||
)
|
||||
}
|
||||
|
||||
fn output_number(index: usize) -> Result<u32, CosError> {
|
||||
u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() })
|
||||
}
|
||||
|
||||
@@ -202,24 +202,23 @@ fn unencrypted_metadata_is_left_readable() {
|
||||
let _ = ObjRef::new(1, 0);
|
||||
}
|
||||
|
||||
/// Second independent oracle when qpdf is installed: it must accept both passwords, identify
|
||||
/// the owner password, and find no structural errors.
|
||||
/// The second independent oracle: qpdf must accept both passwords, identify the owner password,
|
||||
/// and find no structural errors. For R2-R4 this is the *only* check of owner-password
|
||||
/// authentication (Algorithm 7) that does not go through our own code, so it is the one place a
|
||||
/// silent skip would cost the most -- see `pdfcraft-oracle`, which makes absence loud.
|
||||
#[test]
|
||||
fn qpdf_agrees() {
|
||||
if std::process::Command::new("qpdf").arg("--version").output().is_err() {
|
||||
eprintln!("qpdf not installed; skipping");
|
||||
return;
|
||||
}
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for alg in ALL {
|
||||
let path = dir.join(format!("{alg:?}.pdf"));
|
||||
std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap();
|
||||
for (pw, expect) in [("user", "user password"), ("owner", "owner password")] {
|
||||
let out = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap();
|
||||
let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains(&format!("Supplied password is {expect}")), "{alg:?}/{pw}: {text}");
|
||||
let check = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap();
|
||||
let check = qpdf.command().arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap();
|
||||
assert!(check.status.success(), "{alg:?}/{pw}: {}", String::from_utf8_lossy(&check.stdout));
|
||||
}
|
||||
}
|
||||
@@ -279,3 +278,99 @@ fn signature_contents_stay_unencrypted() {
|
||||
assert_eq!(string(2, b"Contents"), b"Private note", "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() {
|
||||
// A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is
|
||||
// meaningless and is read as if absent (128 bits), not multiplied into an overflow (#1816).
|
||||
let original = protect(Algorithm::Aes128, "", "owner", -1);
|
||||
for length in [i64::MIN, i64::MIN / 8 - 1, i64::MIN / 8, -1, 0, 16, 128] {
|
||||
let mut doc = Document::open(Arc::new(original.clone())).unwrap();
|
||||
let encrypt = doc.trailer().reference(b"Encrypt").unwrap();
|
||||
doc.update_dict(encrypt, |e| {
|
||||
let Some(Object::Dict(cf)) = e.get(b"CF") else { panic!("no /CF") };
|
||||
let Some(Object::Dict(std_cf)) = cf.get(b"StdCF") else { panic!("no /StdCF") };
|
||||
let (mut cf, mut std_cf) = (cf.clone(), std_cf.clone());
|
||||
std_cf.set(b"Length".to_vec(), Object::Int(length));
|
||||
cf.set(b"StdCF".to_vec(), Object::Dict(std_cf));
|
||||
e.set(b"CF".to_vec(), Object::Dict(cf));
|
||||
})
|
||||
.unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let again = Document::open(Arc::new(saved)).unwrap();
|
||||
assert_eq!(content(&again), SECRET, "/Length {length}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_earlier_revision_opens_with_the_documents_own_credentials() {
|
||||
// The bytes a signature covers are an earlier revision of the file: the document that is
|
||||
// open can read them with its own key, so no password has to be kept or asked for again.
|
||||
for alg in ALL {
|
||||
let original = protect(alg, "user", "owner", -1);
|
||||
let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap();
|
||||
let info = doc.trailer().reference(b"Info").unwrap();
|
||||
doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised")))).unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let current = Document::open_with_password(Arc::new(saved), Some("user")).unwrap();
|
||||
assert!(matches!(Document::open(Arc::new(original.clone())), Err(CosError::NeedsPassword)), "{alg:?}: not without a password");
|
||||
let earlier = current.open_with_same_credentials(Arc::new(original.clone())).unwrap();
|
||||
assert_eq!(content(&earlier), SECRET, "{alg:?}: the earlier revision is readable");
|
||||
assert_eq!(title(&earlier).as_deref(), Some("Quarterly numbers"), "{alg:?}: and it is the earlier one");
|
||||
// Another file, encrypted with other keys: the document's key is no use, and it says so.
|
||||
let other = {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [3; 32],
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
};
|
||||
assert!(matches!(current.open_with_same_credentials(Arc::new(other)), Err(CosError::NeedsPassword)), "{alg:?}");
|
||||
// An unencrypted file opens as it always does.
|
||||
assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `protect`, with the `/Encrypt` dictionary written inline in the trailer, as MuPDF writes it,
|
||||
/// instead of as an indirect object.
|
||||
fn protect_inline(alg: Algorithm, user: &str, owner: &str) -> Vec<u8> {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: user,
|
||||
owner_password: owner,
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [9; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap();
|
||||
doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict));
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// MuPDF writes `/Encrypt` inline in the trailer, so removing it changes no object. It is still
|
||||
/// an unsaved change, and saving must write a file that opens without a password (#2661).
|
||||
#[test]
|
||||
fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() {
|
||||
for alg in ALL {
|
||||
let bytes = Arc::new(protect_inline(alg, "user", "owner"));
|
||||
assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}");
|
||||
let mut doc = Document::open_with_password(bytes, Some("owner")).unwrap();
|
||||
assert!(matches!(doc.trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}: inline");
|
||||
assert!(!doc.is_modified(), "{alg:?}");
|
||||
doc.remove_encryption();
|
||||
assert!(doc.is_modified(), "{alg:?}: removing security is an unsaved change");
|
||||
let plain = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let again = Document::open(Arc::new(plain.clone())).unwrap_or_else(|e| panic!("{alg:?}: {e:?}"));
|
||||
assert!(again.security().is_none() && !again.trailer().contains(b"Encrypt"), "{alg:?}");
|
||||
assert_eq!(content(&again), SECRET, "{alg:?}");
|
||||
assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}");
|
||||
}
|
||||
}
|
||||
+147
-39
@@ -4,34 +4,23 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
|
||||
use pdfcraft_testkit::Pdf;
|
||||
|
||||
/// Four pages, a shared font, document info with a non-ASCII title.
|
||||
fn fixture() -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..4).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
let mut pdf = Pdf::new()
|
||||
.version("1.4")
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" ")))
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
|
||||
for i in 0..4 {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
pdf = pdf
|
||||
.obj(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i))
|
||||
.stream("", format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1));
|
||||
}
|
||||
objs.push("<< /Title (Caf\\351 \\(draft\\)) >>".into());
|
||||
let mut out = b"%PDF-1.4\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 /Info {} 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len()).as_bytes(),
|
||||
);
|
||||
out
|
||||
let info = pdf.next_obj();
|
||||
pdf.obj("<< /Title (Caf\\351 \\(draft\\)) >>").trailer_entry(&format!("/Info {info} 0 R")).build()
|
||||
}
|
||||
|
||||
fn title(doc: &Document) -> Vec<u8> {
|
||||
@@ -159,18 +148,14 @@ fn classic_table() -> Vec<u8> {
|
||||
bytes
|
||||
}
|
||||
|
||||
fn qpdf_check(bytes: &[u8]) -> Option<std::process::Output> {
|
||||
let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else {
|
||||
return None;
|
||||
};
|
||||
assert!(version.status.success());
|
||||
fn qpdf_check(qpdf: &pdfcraft_oracle::Oracle, bytes: &[u8]) -> std::process::Output {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap();
|
||||
let check = qpdf.command().arg("--check").arg(&path).output().unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Some(check)
|
||||
check
|
||||
}
|
||||
|
||||
fn page(doc: &Document) -> Dict {
|
||||
@@ -181,10 +166,8 @@ fn page(doc: &Document) -> Dict {
|
||||
}
|
||||
|
||||
fn assert_qpdf_clean(bytes: &[u8]) {
|
||||
let Some(check) = qpdf_check(bytes) else {
|
||||
eprintln!("qpdf not installed: skipped");
|
||||
return;
|
||||
};
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
let check = qpdf_check(&qpdf, bytes);
|
||||
let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr));
|
||||
assert!(check.status.success() && !report.contains("WARNING"), "{report}");
|
||||
}
|
||||
@@ -247,16 +230,12 @@ fn incremental_save_keeps_a_trailer_only_edit() {
|
||||
|
||||
#[test]
|
||||
fn qpdf_accepts_object_stream_output() {
|
||||
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {
|
||||
eprintln!("qpdf not installed: skipped");
|
||||
return;
|
||||
};
|
||||
assert!(out.status.success());
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
let doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
let packed = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-objstm-{}.pdf", std::process::id()));
|
||||
std::fs::write(&path, &packed).unwrap();
|
||||
let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap();
|
||||
let check = qpdf.command().arg("--check").arg(&path).output().unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let report = String::from_utf8_lossy(&check.stdout);
|
||||
assert!(check.status.success() && report.contains("No syntax or stream encoding errors"), "{report}");
|
||||
@@ -348,3 +327,132 @@ fn page_tree_loops_in_object_streams_do_not_crash_readers() {
|
||||
assert!(String::from_utf8_lossy(&packed).contains("/ObjStm"));
|
||||
assert_eq!(hayro_pages(&packed), 1);
|
||||
}
|
||||
|
||||
/// Catalog, empty page tree, and trailer entries a reader must not invent: a direct name, a
|
||||
/// direct string, and a string object reachable only from the trailer.
|
||||
fn trailer_fixture() -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(only from the trailer)"].iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!("trailer\n<< /Size 4 /Root 1 0 R /Version /1.7 /Custom (keep me) /Note 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn trailer_custom(doc: &Document) -> Option<Vec<u8>> {
|
||||
doc.trailer().get(b"Custom").and_then(Object::as_string).map(|s| s.bytes.clone())
|
||||
}
|
||||
|
||||
fn trailer_note(doc: &Document) -> Option<Vec<u8>> {
|
||||
doc.trailer().get(b"Note").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_save_preserves_unknown_trailer_data() {
|
||||
let input = trailer_fixture();
|
||||
let doc = Document::open(Arc::new(input)).unwrap();
|
||||
assert_eq!(trailer_custom(&doc), Some(b"keep me".to_vec()));
|
||||
assert_eq!(trailer_note(&doc).as_deref(), Some(b"only from the trailer".as_slice()));
|
||||
assert_eq!(doc.trailer().get(b"Version").and_then(Object::as_name), Some(&b"1.7"[..]));
|
||||
assert!(doc.repair_log().is_empty());
|
||||
assert!(!doc.full_save_required());
|
||||
|
||||
let inc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(trailer_custom(&inc), trailer_custom(&doc));
|
||||
assert_eq!(trailer_note(&inc), trailer_note(&doc));
|
||||
assert_eq!(inc.trailer().get(b"Version"), doc.trailer().get(b"Version"));
|
||||
assert!(inc.repair_log().is_empty());
|
||||
|
||||
for object_streams in [true, false] {
|
||||
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
|
||||
let bytes = write_full(&doc, &opts).unwrap();
|
||||
let back = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(back.root().is_some());
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_eq!(trailer_custom(&back), trailer_custom(&doc));
|
||||
assert_eq!(trailer_note(&back), trailer_note(&doc));
|
||||
assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version"));
|
||||
}
|
||||
|
||||
let mut required = doc.clone();
|
||||
required.require_full_save();
|
||||
let back = Document::open(Arc::new(write_incremental(&required, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert!(back.root().is_some());
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_eq!(trailer_custom(&back), trailer_custom(&doc));
|
||||
assert_eq!(trailer_note(&back), trailer_note(&doc));
|
||||
assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version"));
|
||||
}
|
||||
|
||||
/// The `startxref` value at the end of `bytes`.
|
||||
fn last_startxref(bytes: &[u8]) -> usize {
|
||||
let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap();
|
||||
String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_saves_of_a_file_with_bytes_before_its_header_count_from_the_header() {
|
||||
// Offsets written before the prefix was added count from `%PDF-`, as in the KillerPDF file
|
||||
// of #2364; the appended section must count the same way as the `/Prev` it points back to.
|
||||
let prefix = vec![b'#'; 128];
|
||||
let as_stream = write_full(&Document::open(Arc::new(fixture())).unwrap(), &SaveOptions::default()).unwrap();
|
||||
for (name, body, marker) in [("table", classic_table(), &b"xref"[..]), ("stream", as_stream, &b" 0 obj"[..])] {
|
||||
let input = [prefix.as_slice(), &body].concat();
|
||||
let mut doc = Document::open(Arc::new(input.clone())).unwrap();
|
||||
assert!(doc.revisions().last().unwrap().header_relative, "{name}");
|
||||
doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(saved.starts_with(&input), "{name}");
|
||||
|
||||
let xref = last_startxref(&saved) + prefix.len();
|
||||
let section = saved.get(xref..).unwrap_or_default();
|
||||
assert!(section.windows(marker.len()).position(|w| w == marker).is_some_and(|p| p < 16), "{name}: startxref misses its section");
|
||||
let back = Document::open(Arc::new(saved.clone())).unwrap();
|
||||
assert_eq!(back.revisions().len(), doc.revisions().len() + 1, "{name}");
|
||||
assert!(back.revisions().iter().all(|r| r.header_relative), "{name}: one convention across the chain");
|
||||
let edited = back.get(ObjRef::new(1, 0));
|
||||
assert_eq!(edited.as_dict().unwrap().get(b"Lang").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"en".as_slice()));
|
||||
assert!(back.xref_entry_is_exact(1), "{name}");
|
||||
assert_qpdf_clean(&saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so() {
|
||||
// The other convention: the same file, but its offsets include the bytes before the header.
|
||||
let prefix = vec![b'#'; 128];
|
||||
let mut input = [prefix.as_slice(), &classic_table()].concat();
|
||||
let xref = input.windows(6).rposition(|w| w == b"\nxref\n").unwrap() + 1;
|
||||
let table: String = String::from_utf8_lossy(&input[xref..])
|
||||
.lines()
|
||||
.map(|line| match line.strip_suffix(" 00000 n ").and_then(|o| o.parse::<usize>().ok()) {
|
||||
Some(o) => format!("{:010} 00000 n \n", o + prefix.len()),
|
||||
None if line.parse::<usize>().is_ok() => format!("{xref}\n"),
|
||||
None => format!("{line}\n"),
|
||||
})
|
||||
.collect();
|
||||
input.truncate(xref);
|
||||
input.extend_from_slice(table.as_bytes());
|
||||
|
||||
let mut doc = Document::open(Arc::new(input.clone())).unwrap();
|
||||
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
|
||||
assert!(!doc.revisions().last().unwrap().header_relative);
|
||||
doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(saved.get(last_startxref(&saved)..).unwrap_or_default().starts_with(b"xref"));
|
||||
let back = Document::open(Arc::new(saved.clone())).unwrap();
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_eq!(back.revisions().len(), 2);
|
||||
assert!(back.xref_entry_is_exact(1));
|
||||
// No qpdf check here: qpdf counts every offset from the header in a file with bytes before
|
||||
// it, so it already reconstructs this input before anything is appended.
|
||||
}
|
||||
@@ -23,3 +23,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
|
||||
line says so (`ESCAPED_NOTE`).
|
||||
|
||||
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
|
||||
|
||||
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
|
||||
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
|
||||
Missing, null or malformed Decode arrays keep the default mapping.
|
||||
The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited.
|
||||
ICC transforms and calibrated color conversion remain outside this export path.
|
||||
@@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
|
||||
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
|
||||
let key = if mask { None } else { colour_key(doc, s) };
|
||||
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case.
|
||||
let inverted = s
|
||||
.dict
|
||||
.get(b"Decode")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
|
||||
.unwrap_or(false);
|
||||
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
|
||||
let inverted = (jpeg || mask)
|
||||
&& s.dict
|
||||
.get(b"Decode")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
|
||||
.unwrap_or(false);
|
||||
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
|
||||
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
|
||||
// a negative.
|
||||
@@ -161,6 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
(unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc)
|
||||
};
|
||||
let n = space.components();
|
||||
let decode = if jpeg || mask { None } else { decode(doc, s, n) };
|
||||
if alpha.is_none() {
|
||||
alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k));
|
||||
}
|
||||
@@ -168,10 +169,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
return as_is();
|
||||
}
|
||||
let invert = inverted ^ mask; // a stencil mask paints its 0 samples
|
||||
// `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too.
|
||||
let max = (1u32 << bpc.min(8)) - 1;
|
||||
let mut rgb = Vec::with_capacity(w * h * 3);
|
||||
for px in samples.chunks_exact(n) {
|
||||
let decoded = |i: usize| {
|
||||
decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| {
|
||||
let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max);
|
||||
// Avoid overflowing hi - lo for finite endpoints with opposite signs.
|
||||
(1.0 - t) * lo + t * hi
|
||||
})
|
||||
};
|
||||
let v = |i: usize| {
|
||||
if let Some(value) = decoded(i) {
|
||||
return (value.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8;
|
||||
if invert { 255 - x } else { x }
|
||||
};
|
||||
@@ -179,8 +191,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
Space::Gray => rgb.extend_from_slice(&[v(0); 3]),
|
||||
Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]),
|
||||
Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))),
|
||||
Space::Indexed(base, table) => {
|
||||
let i = px.first().copied().unwrap_or(0) as usize;
|
||||
Space::Indexed(base, hival, table) => {
|
||||
// Explicit Decode maps into index units, rounded then clipped (8.6.6.3).
|
||||
// Without Decode, retain raw indices rather than normalizing to hival.
|
||||
let i = match decoded(0) {
|
||||
Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize,
|
||||
None => px.first().copied().unwrap_or(0).min(*hival) as usize,
|
||||
};
|
||||
let k = base.components();
|
||||
let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]);
|
||||
rgb.extend_from_slice(&match **base {
|
||||
@@ -197,6 +214,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p))
|
||||
}
|
||||
|
||||
/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90).
|
||||
///
|
||||
/// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives
|
||||
/// the default mapping, so the image still exports.
|
||||
fn decode(doc: &Document, s: &Stream, n: usize) -> Option<Vec<[f64; 2]>> {
|
||||
let value = doc.resolve(s.dict.get(b"Decode")?);
|
||||
let values = value.as_array()?;
|
||||
if Some(values.len()) != n.checked_mul(2) {
|
||||
return None;
|
||||
}
|
||||
let endpoint = |value: &Object| doc.resolve(value).as_f64().filter(|v| v.is_finite());
|
||||
let mut pairs = Vec::with_capacity(n);
|
||||
for [lo, hi] in values.as_chunks::<2>().0 {
|
||||
pairs.push([endpoint(lo)?, endpoint(hi)?]);
|
||||
}
|
||||
Some(pairs)
|
||||
}
|
||||
|
||||
/// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`.
|
||||
fn size(w: i64, h: i64) -> Option<(usize, usize)> {
|
||||
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
|
||||
@@ -310,8 +345,8 @@ enum Space {
|
||||
Gray,
|
||||
Rgb,
|
||||
Cmyk,
|
||||
/// Base space and lookup table (one byte per base component).
|
||||
Indexed(Box<Space>, Vec<u8>),
|
||||
/// Base space, highest valid palette index, and lookup table.
|
||||
Indexed(Box<Space>, u8, Vec<u8>),
|
||||
}
|
||||
|
||||
impl Space {
|
||||
@@ -352,12 +387,16 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
|
||||
if matches!(base, Space::Indexed(..)) {
|
||||
return Err("nested indexed colour spaces aren't valid".into());
|
||||
}
|
||||
let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() {
|
||||
Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8,
|
||||
_ => return Err("an indexed colour space with an invalid hival".into()),
|
||||
};
|
||||
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
|
||||
Some(Object::String(s)) => s.bytes.clone(),
|
||||
Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?,
|
||||
_ => return Err("an indexed colour space without a lookup table".into()),
|
||||
};
|
||||
Ok(Space::Indexed(Box::new(base), table))
|
||||
Ok(Space::Indexed(Box::new(base), hival, table))
|
||||
}
|
||||
Some(other) => unsupported(other),
|
||||
None => Err("a damaged colour space".into()),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
mod extract;
|
||||
mod upright_image;
|
||||
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
|
||||
use pdfcraft_fonts::{literal, win_ansi, wrap};
|
||||
|
||||
@@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
|
||||
/// Detect the image format from its bytes; a TIFF may hold several pages.
|
||||
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
Ok(vec![jpeg(name, bytes)?])
|
||||
Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
|
||||
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Ok(vec![png_image(name, bytes)?])
|
||||
} else if is_tiff(bytes) {
|
||||
|
||||
@@ -124,6 +124,18 @@ fn text_is_wrapped_and_paginated() {
|
||||
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_leading_spaces_and_tabs() {
|
||||
let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap());
|
||||
let p = pages(&doc);
|
||||
let c = doc.resolve(p[0].get(b"Contents").unwrap());
|
||||
let Object::Stream(s) = &*c else { panic!() };
|
||||
let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
|
||||
assert!(content.contains("( indented) Tj"), "{content}");
|
||||
assert!(content.contains("( tabbed) Tj"), "{content}");
|
||||
assert!(content.contains("(plain) Tj"), "{content}");
|
||||
}
|
||||
|
||||
/// Text Helvetica can't draw is refused with its line and character, never set as `?`; text
|
||||
/// PdfCraft generates writes such characters as [U+XXXX] and says so.
|
||||
#[test]
|
||||
@@ -744,6 +756,324 @@ fn damaged_masks_and_sizes_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_image_export_clamps_samples_to_hival() {
|
||||
for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let image = xo.iter().next().unwrap().1.as_ref().unwrap();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
dict.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
dict.set(b"Width".to_vec(), Object::Int(4));
|
||||
dict.set(b"Height".to_vec(), Object::Int(1));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(2));
|
||||
dict.set(
|
||||
b"ColorSpace".to_vec(),
|
||||
Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::String(PdfString::literal(palette))]),
|
||||
);
|
||||
// Default Decode preserves sample values0,1,2,3. Values2 and3 clamp to hival1.
|
||||
doc.set(image, Object::Stream(Stream::from_raw(dict, vec![0b0001_1011])));
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
assert_eq!(out.images.len(), 1);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
fn exported_png_pixels(bytes: &[u8]) -> Vec<u8> {
|
||||
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut pixels).unwrap();
|
||||
pixels.truncate(info.buffer_size());
|
||||
pixels
|
||||
}
|
||||
|
||||
fn indexed_export_fixture(
|
||||
hival: Object,
|
||||
palette: Vec<u8>,
|
||||
stream_table: bool,
|
||||
indirect_hival: bool,
|
||||
bpc: i64,
|
||||
samples: Vec<u8>,
|
||||
) -> (Document, pdfcraft_cos::ObjRef) {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let image = xo.iter().next().unwrap().1.as_ref().unwrap();
|
||||
let table = if stream_table {
|
||||
Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &palette))))
|
||||
} else {
|
||||
Object::String(PdfString::literal(palette))
|
||||
};
|
||||
let hival = if indirect_hival { Object::Ref(doc.add(hival)) } else { hival };
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
dict.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
dict.set(b"Width".to_vec(), Object::Int(4));
|
||||
dict.set(b"Height".to_vec(), Object::Int(1));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(bpc));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), hival, table]));
|
||||
doc.set(image, Object::Stream(Stream::from_raw(dict, samples)));
|
||||
(doc, image)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_resolves_indirect_values_and_stream_palettes() {
|
||||
for (bpc, samples) in [(2, vec![0x1b]), (4, vec![0x01, 0x23]), (8, vec![0, 1, 2, 255])] {
|
||||
let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], true, true, bpc, samples);
|
||||
let (ext, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
assert_eq!(ext, "png");
|
||||
assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_boundary_values_keep_valid_palette_entries() {
|
||||
let palette: Vec<u8> = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect();
|
||||
for high in [0, 255] {
|
||||
let (doc, image) = indexed_export_fixture(Object::Int(high), palette.clone(), false, false, 8, vec![0, 1, 254, 255]);
|
||||
let (_, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
let expected: Vec<u8> = [0u8, 1, 254, 255]
|
||||
.into_iter()
|
||||
.flat_map(|i| {
|
||||
let i = i.min(high as u8);
|
||||
[i, 255 - i, 17]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(exported_png_pixels(&bytes), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_rejects_invalid_values_without_panicking() {
|
||||
for high in [Object::Int(-1), Object::Int(256), Object::Real(1.0), Object::Null, Object::name("bad")] {
|
||||
let (doc, image) = indexed_export_fixture(high, vec![255, 0, 0], false, false, 8, vec![0; 4]);
|
||||
assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival"));
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.images.is_empty());
|
||||
assert_eq!(out.skipped.len(), 1);
|
||||
assert!(out.skipped[0].2.contains("invalid hival"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_clamping_preserves_raw_colour_key_mask_samples() {
|
||||
let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]);
|
||||
let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") };
|
||||
stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)]));
|
||||
doc.set(image, Object::Stream(stream));
|
||||
let (_, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_missing_element_returns_export_error() {
|
||||
let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 8, vec![0; 4]);
|
||||
let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") };
|
||||
stream.dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB")]));
|
||||
doc.set(image, Object::Stream(stream));
|
||||
assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_keeps_existing_short_palette_fallback() {
|
||||
let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 2, vec![0x1b]);
|
||||
let (_, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
/// Save and reopen a synthetic image before checking both export entry points.
|
||||
fn decode_fixture(dict: Dict, samples: Vec<u8>) -> (Document, pdfcraft_cos::ObjRef) {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |_| Stream::from_raw(dict, samples));
|
||||
let doc = reopen(&doc);
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap());
|
||||
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
let image = xo.as_dict().unwrap().iter().next().unwrap().1.as_ref().unwrap();
|
||||
(doc, image)
|
||||
}
|
||||
|
||||
fn assert_decode_pixels(dict: Dict, samples: Vec<u8>, expected: &[u8]) {
|
||||
let (doc, image) = decode_fixture(dict, samples);
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
assert_eq!(out.images.len(), 1);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), expected);
|
||||
let (ext, bytes) = image_file(&doc, image).unwrap();
|
||||
assert_eq!(ext, "png");
|
||||
assert_eq!(exported_png_pixels(&bytes), expected);
|
||||
}
|
||||
|
||||
fn decode_array(values: &[f64]) -> Object {
|
||||
Object::Array(values.iter().copied().map(Object::Real).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_decode_maps_each_component_before_colour_conversion() {
|
||||
for (space, samples, decode, expected) in [
|
||||
("DeviceRGB", vec![0, 64, 255], vec![1., 0., 0., 1., 0., 1.], [255, 64, 255]),
|
||||
("DeviceRGB", vec![0, 64, 255], vec![0., 1., 1., 0., 0., 1.], [0, 191, 255]),
|
||||
("DeviceGray", vec![0], vec![0.2, 0.8], [51, 51, 51]),
|
||||
("DeviceGray", vec![128], vec![-1., 2.], [129, 129, 129]),
|
||||
("DeviceCMYK", vec![0, 0, 0, 0], vec![0., 1., 1., 0., 0., 1., 0., 1.], [255, 0, 255]),
|
||||
] {
|
||||
let mut dict = image_dict(1, 1, space, 8);
|
||||
dict.set(b"Decode".to_vec(), decode_array(&decode));
|
||||
assert_decode_pixels(dict, samples, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_decode_scales_packed_samples_by_their_bit_depth() {
|
||||
let mut dict = image_dict(4, 1, "DeviceGray", 2);
|
||||
dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8]));
|
||||
assert_decode_pixels(dict, vec![0x1b], &[51, 51, 51, 102, 102, 102, 153, 153, 153, 204, 204, 204]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_decode_identity_and_defaults_keep_depth_and_row_padding() {
|
||||
for (bpc, samples, expected) in [
|
||||
(1, vec![0x7f, 0xff], vec![0, 0, 0, 255, 255, 255]),
|
||||
(2, vec![0x7f, 0xbf], vec![85, 85, 85, 170, 170, 170]),
|
||||
(4, vec![0x5f, 0xaf], vec![85, 85, 85, 170, 170, 170]),
|
||||
(8, vec![85, 170], vec![85, 85, 85, 170, 170, 170]),
|
||||
(16, vec![85, 255, 170, 0], vec![85, 85, 85, 170, 170, 170]),
|
||||
] {
|
||||
for decode in [None, Some(Object::Null), Some(decode_array(&[0., 1.]))] {
|
||||
let mut dict = image_dict(1, 2, "DeviceGray", bpc);
|
||||
if let Some(decode) = decode {
|
||||
dict.set(b"Decode".to_vec(), decode);
|
||||
}
|
||||
assert_decode_pixels(dict, samples.clone(), &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_indexed_decode_rounds_in_index_units_then_clamps() {
|
||||
let palette = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120];
|
||||
for (bpc, samples, decode, expected) in [
|
||||
(2, vec![0x1b], Some(vec![3., 0.]), vec![100, 110, 120, 70, 80, 90, 40, 50, 60, 10, 20, 30]),
|
||||
(2, vec![0x1b], Some(vec![-1., 5.]), vec![10, 20, 30, 40, 50, 60, 100, 110, 120, 100, 110, 120]),
|
||||
(2, vec![0x1b], Some(vec![0.5, 0.5]), vec![40, 50, 60, 40, 50, 60, 40, 50, 60, 40, 50, 60]),
|
||||
(8, vec![0, 1, 2, 255], None, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]),
|
||||
// Existing 16-bit policy retains the high byte, 42 / 255 * 3 < 0.5.
|
||||
(16, vec![0x2a, 0xab, 0, 0, 255, 255, 128, 0], Some(vec![0., 3.]), vec![10, 20, 30, 10, 20, 30, 100, 110, 120, 70, 80, 90]),
|
||||
] {
|
||||
let (mut doc, image) = indexed_export_fixture(Object::Int(3), palette.clone(), false, false, bpc, samples);
|
||||
if let Some(decode) = decode {
|
||||
let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() };
|
||||
stream.dict.set(b"Decode".to_vec(), decode_array(&decode));
|
||||
doc.set(image, Object::Stream(stream));
|
||||
}
|
||||
let doc = reopen(&doc);
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), expected);
|
||||
let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_decode_keeps_colour_key_comparisons_in_raw_samples() {
|
||||
let mut dict = image_dict(2, 1, "DeviceGray", 8);
|
||||
dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8]));
|
||||
dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0)]));
|
||||
assert_decode_pixels(dict, vec![0, 255], &[51, 51, 51, 0, 204, 204, 204, 255]);
|
||||
|
||||
let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]);
|
||||
let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() };
|
||||
stream.dict.set(b"Decode".to_vec(), decode_array(&[3., 0.]));
|
||||
stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)]));
|
||||
doc.set(image, Object::Stream(stream));
|
||||
let doc = reopen(&doc);
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
let expected = [0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, 255];
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), expected);
|
||||
let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_decode_resolves_indirect_arrays_and_endpoints() {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let lo = doc.add(Object::Real(0.2));
|
||||
let hi = doc.add(Object::Real(0.8));
|
||||
let decode = doc.add(Object::Array(vec![Object::Ref(lo), Object::Ref(hi)]));
|
||||
let mut dict = image_dict(1, 1, "DeviceGray", 8);
|
||||
dict.set(b"Decode".to_vec(), Object::Ref(decode));
|
||||
Stream::from_raw(dict, vec![0])
|
||||
});
|
||||
let doc = reopen(&doc);
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), [51, 51, 51]);
|
||||
let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), [51, 51, 51]);
|
||||
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let decode = doc.add(Object::Null);
|
||||
let mut dict = image_dict(1, 1, "DeviceGray", 8);
|
||||
dict.set(b"Decode".to_vec(), Object::Ref(decode));
|
||||
Stream::from_raw(dict, vec![64])
|
||||
});
|
||||
let doc = reopen(&doc);
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]);
|
||||
let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() {
|
||||
let (mut doc, image) = decode_fixture(image_dict(3, 1, "DeviceGray", 8), vec![0, 64, 255]);
|
||||
let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() };
|
||||
stream.dict.set(b"Decode".to_vec(), decode_array(&[-f64::MAX, f64::MAX]));
|
||||
doc.set(image, Object::Stream(stream));
|
||||
let expected = [0, 0, 0, 0, 0, 0, 255, 255, 255];
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), expected);
|
||||
let (_, bytes) = image_file(&doc, image).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_decode_falls_back_to_the_default_mapping_when_malformed() {
|
||||
for (space, samples, decode, expected) in [
|
||||
("DeviceRGB", vec![0, 64, 255], decode_array(&[1., 0.]), [0, 64, 255]),
|
||||
("DeviceGray", vec![64], Object::name("bad"), [64, 64, 64]),
|
||||
("DeviceGray", vec![64], decode_array(&[]), [64, 64, 64]),
|
||||
("DeviceGray", vec![64], decode_array(&[0.]), [64, 64, 64]),
|
||||
("DeviceGray", vec![64], decode_array(&[1., 0., 0., 1.]), [64, 64, 64]),
|
||||
("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::name("bad")]), [64, 64, 64]),
|
||||
("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::Null]), [64, 64, 64]),
|
||||
] {
|
||||
let mut dict = image_dict(1, 1, space, 8);
|
||||
dict.set(b"Decode".to_vec(), decode);
|
||||
assert_decode_pixels(dict, samples, &expected);
|
||||
}
|
||||
// Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input.
|
||||
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut dict = image_dict(1, 1, "DeviceGray", 8);
|
||||
dict.set(b"Decode".to_vec(), decode_array(&[1., value]));
|
||||
let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![64]);
|
||||
doc.set(image, Object::Stream(Stream::from_raw(dict, vec![64])));
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]);
|
||||
let (_, bytes) = image_file(&doc, image).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an
|
||||
/// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK).
|
||||
fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec<u8> {
|
||||
@@ -818,3 +1148,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees
|
||||
/// clockwise).
|
||||
pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() {
|
||||
let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap());
|
||||
// Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt.
|
||||
let m0 = media(&pages(&doc)[0]);
|
||||
assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}");
|
||||
let img = image_of(&doc, 0);
|
||||
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60)));
|
||||
// "As stored" stays as it is.
|
||||
let m1 = media(&pages(&doc)[1]);
|
||||
assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}");
|
||||
let img = image_of(&doc, 1);
|
||||
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20)));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
|
||||
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
|
||||
//! (stamps, added images, replacements) go through here first.
|
||||
//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this
|
||||
//! crate (pages made from images, stamps, added images, replacements) goes through here first.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
@@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Edit, MarkFile};
|
||||
|
||||
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
|
||||
/// degrees clockwise).
|
||||
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
use crate::tests::phone_jpeg;
|
||||
|
||||
#[test]
|
||||
fn orientation_is_applied_and_plain_images_are_left_alone() {
|
||||
@@ -111,23 +94,4 @@ mod tests {
|
||||
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
|
||||
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
|
||||
let mut session = crate::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
|
||||
// Named "Signature" so `signature_image` finds its picture.
|
||||
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
|
||||
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
|
||||
session.apply(id, stamp).unwrap();
|
||||
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
|
||||
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
|
||||
let obj = cos.get(picture);
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
|
||||
let rect = pdfcraft_annot::summaries(cos)[0].rect;
|
||||
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -203,8 +203,22 @@ impl SecurityHandler {
|
||||
self.recovered_user.clone()
|
||||
}
|
||||
|
||||
/// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key
|
||||
/// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an
|
||||
/// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before.
|
||||
pub fn permissions(&self) -> Permissions {
|
||||
Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner }
|
||||
let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p);
|
||||
Permissions { bits, owner: self.auth == Auth::Owner }
|
||||
}
|
||||
|
||||
/// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`.
|
||||
fn perms_p(&self) -> Option<i32> {
|
||||
if !matches!(self.dict.r, 5 | 6) {
|
||||
return None;
|
||||
}
|
||||
let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
|
||||
let p = i32::from_le_bytes(*plain.first_chunk::<4>()?);
|
||||
(plain.get(9..12) == Some(&b"adb"[..])).then_some(p)
|
||||
}
|
||||
|
||||
pub fn dict(&self) -> &EncryptDict {
|
||||
|
||||
@@ -212,6 +212,28 @@ fn permission_bits_follow_table_22() {
|
||||
assert!(p.modify(), "owners ignore /P");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perms_limits_what_an_edited_p_grants() {
|
||||
let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only
|
||||
let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions();
|
||||
let mut d = made.dict().clone();
|
||||
assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree");
|
||||
d.p = -4; // /P edited to allow everything, /Perms as written
|
||||
let p = open(&d, "user pw");
|
||||
assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble());
|
||||
assert!(open(&d, "owner pw").modify(), "owners ignore /P");
|
||||
d.p = -3904; // a stricter /P still applies
|
||||
assert!(!open(&d, "user pw").print());
|
||||
// No /Perms, or one that does not decrypt to an `adb` block: /P as before.
|
||||
for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] {
|
||||
let d = EncryptDict { p: -4, perms, ..made.dict().clone() };
|
||||
assert!(open(&d, "user pw").modify());
|
||||
}
|
||||
// R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open.
|
||||
let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() };
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60),
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Property tests for what the security handler does to *content*, and to the ciphertext a
|
||||
//! stranger's file hands back.
|
||||
//!
|
||||
//! `src/tests.rs` covers this handler carefully with fixed vectors — including the `/O`, `/U` and
|
||||
//! file keys qpdf 12.4.2 produced for real files — plus one property over malformed dictionaries.
|
||||
//! What it does not vary is the *payload*. Upstream's encrypted round trips run three shapes: a
|
||||
//! 30-byte text, a 1000-byte ramp, and a 10-byte attachment in `identity_crypt_filters_leave_data_alone`.
|
||||
//! The shapes nobody walks are the ones that break CBC and readers: empty content, one block minus
|
||||
//! one, a ciphertext cut short by a damaged `/Length`, and keys of a length `create` never builds.
|
||||
//!
|
||||
//! - plaintext survives encryption at every length, for each algorithm and each crypt filter;
|
||||
//! - truncated or mutated ciphertext produces bytes rather than a panic (`AGENTS.md` §4: PdfCraft
|
||||
//! opens files from strangers, and encrypted streams in the wild do get truncated);
|
||||
//! - object keys are scoped to their object, and AES-256's documented exception still holds;
|
||||
//! - a file opens for exactly the passwords whose effective bytes match, stated as a rule over
|
||||
//! generated Unicode passwords instead of the three examples in `src/tests.rs` (the #1185 rule);
|
||||
//! - the AES-CBC and RC4 primitives invert each other for any key length, since `/Length` and
|
||||
//! crypt filters from a damaged file reach them with shapes `create` would never build.
|
||||
|
||||
use pdfcraft_crypt::{
|
||||
Algorithm, Auth, CryptError, Method, NewEncryption, PADDING, SecurityHandler, StreamKind, aes_cbc_decrypt, aes_cbc_encrypt, create,
|
||||
effective_password, pdfdoc_byte, rc4,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// The first element of `/ID`, which every R2–R4 derivation hashes in (Algorithm 2).
|
||||
const ID: &[u8] = b"\x01\x23\x45\x67\x89\xab\xcd\xef\x0f\xed\xcb\xa9\x87\x65\x43\x21";
|
||||
|
||||
fn algorithms() -> impl Strategy<Value = Algorithm> {
|
||||
prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256])
|
||||
}
|
||||
|
||||
/// Content at the lengths where CBC padding goes wrong, plus anything in between.
|
||||
fn payloads() -> impl Strategy<Value = Vec<u8>> {
|
||||
prop_oneof![
|
||||
Just(Vec::new()),
|
||||
Just(vec![0x5cu8; 1]),
|
||||
Just(vec![0xa7u8; 15]),
|
||||
Just(vec![0x3fu8; 16]),
|
||||
Just(vec![0x9du8; 17]),
|
||||
Just(vec![0x10u8; 32]),
|
||||
prop::collection::vec(any::<u8>(), 1..96),
|
||||
]
|
||||
}
|
||||
|
||||
/// Passwords from an alphabet the two encodings disagree about: SASLprep deletes U+00AD, folds
|
||||
/// U+FF21 to "A", U+FB01 to "fi" and U+3000 to a space (revisions 5–6), while PDFDocEncoding has
|
||||
/// no byte for 'ğ' or CJK at all, which sends revisions 2–4 down their UTF-8 fallback.
|
||||
const PASSWORD_CHARS: [char; 12] = ['a', 'B', '7', ' ', '-', '\u{AD}', '\u{FF21}', '\u{FB01}', 'ı', 'ğ', '日', '\u{A0}'];
|
||||
|
||||
fn passwords() -> impl Strategy<Value = String> {
|
||||
prop::collection::vec(prop::sample::select(PASSWORD_CHARS.to_vec()), 0..12).prop_map(|chars| chars.into_iter().collect())
|
||||
}
|
||||
|
||||
/// `bytes` cut to 32 and padded with the standard padding string (Algorithm 2, step a).
|
||||
fn padded(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = PADDING;
|
||||
let n = bytes.len().min(32);
|
||||
out[..n].copy_from_slice(&bytes[..n]);
|
||||
out.to_vec()
|
||||
}
|
||||
|
||||
/// Every byte string the handler may authenticate `pw` as. Revisions 2–4 use PDFDocEncoding, and
|
||||
/// for a password holding characters it lacks they also try the raw UTF-8 bytes (qpdf writes such
|
||||
/// passwords that way); revisions 5–6 use the SASLprep bytes. `effective_password` publishes the
|
||||
/// first of these per algorithm, and it is what callers such as Protect check before encrypting.
|
||||
fn candidates(algorithm: Algorithm, pw: &str) -> Vec<Vec<u8>> {
|
||||
let mut out = vec![effective_password(algorithm, pw)];
|
||||
if algorithm != Algorithm::Aes256 && pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
|
||||
out.push(padded(&pw.as_bytes().iter().copied().take(32).collect::<Vec<u8>>()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What `create` keys `/O` from: the owner password, except on revisions 2–4 with no owner
|
||||
/// password given, where it falls back to the user password's bytes.
|
||||
fn owner_target(algorithm: Algorithm, user: &str, owner: &str) -> Vec<u8> {
|
||||
if algorithm != Algorithm::Aes256 && owner.is_empty() { effective_password(algorithm, user) } else { effective_password(algorithm, owner) }
|
||||
}
|
||||
|
||||
/// Which password `pw` is for this file. Owners are tried before users, so bytes that match both
|
||||
/// authenticate as owner — the order viewers use. `None` means it opens nothing.
|
||||
fn expected_auth(algorithm: Algorithm, user_target: &[u8], owner_target: &[u8], pw: &str) -> Option<Auth> {
|
||||
let tried = candidates(algorithm, pw);
|
||||
if tried.iter().any(|c| c == owner_target) {
|
||||
Some(Auth::Owner)
|
||||
} else if tried.iter().any(|c| c == user_target) {
|
||||
Some(Auth::User)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn make(algorithm: Algorithm, user: &str, owner: &str) -> SecurityHandler {
|
||||
create(&NewEncryption { algorithm, user_password: user, owner_password: owner, permissions: -1, encrypt_metadata: true, seed: [0x5e; 32] }, ID)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Encrypting then decrypting returns the input at every length, as a string and as each kind
|
||||
/// of stream — and is byte-for-byte reproducible, because re-running a save must not silently
|
||||
/// produce different bytes. Payloads of at least one block must come back changed: RC4 would
|
||||
/// need an all-zero keystream to leave them alone, and CBC cannot shrink a padded block.
|
||||
#[test]
|
||||
fn content_survives_the_trip_at_every_length(alg in algorithms(), data in payloads()) {
|
||||
let h = make(alg, "user", "owner");
|
||||
|
||||
// The key width each algorithm promises: 5 bytes for export-grade 40-bit RC4, 16 for
|
||||
// AES-128 and RC4-128, 32 for AES-256. `key_len` is the only place that decides this and
|
||||
// nothing in either suite reads it back, so `if d.r == 2 { 5 }` becoming `{ 4 }` today
|
||||
// produces a file key of the wrong size that every test still calls fine.
|
||||
let want_len = match alg { Algorithm::Rc4_40 => 5, Algorithm::Aes128 | Algorithm::Rc4_128 => 16, Algorithm::Aes256 => 32 };
|
||||
prop_assert_eq!(h.file_key().len(), want_len, "{:?} must use a {} byte file key", alg, want_len);
|
||||
|
||||
let h = make(alg, "user", "owner");
|
||||
for num in [0u32, 1, 4096, u32::MAX] {
|
||||
let generation = (num % 65_535) as u16;
|
||||
|
||||
let enc = h.encrypt_string(num, generation, &data);
|
||||
prop_assert_eq!(h.decrypt_string(num, generation, &enc), &data[..], "string round trip at {} bytes ({:?})", data.len(), alg);
|
||||
prop_assert_eq!(h.encrypt_string(num, generation, &data), enc, "string encryption must be reproducible ({:?})", alg);
|
||||
|
||||
for kind in [StreamKind::Normal, StreamKind::EmbeddedFile, StreamKind::Named(b"StdCF")] {
|
||||
let enc = h.encrypt_stream(num, generation, &data, kind);
|
||||
prop_assert_eq!(h.decrypt_stream(num, generation, &enc, kind), &data[..], "stream round trip at {} bytes ({:?}, {:?})", data.len(), alg, kind);
|
||||
prop_assert_eq!(h.encrypt_stream(num, generation, &data, kind), enc, "{:?} {:?} encryption must be reproducible", alg, kind);
|
||||
}
|
||||
|
||||
if data.len() >= 16 {
|
||||
prop_assert_ne!(h.encrypt_string(num, generation, &data), &data[..], "one block left in plain sight ({:?})", alg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ciphertext of any length — truncated by a damaged `/Length`, padded out with garbage, or
|
||||
/// byte-flipped inside — must decrypt to bytes and never read past the end. AES discards a
|
||||
/// trailing partial block and leaves invalid padding in place rather than failing, so its
|
||||
/// output can only be a whole number of blocks minus at most one; RC4 keeps its length
|
||||
/// whatever the bytes are. An input shorter than the IV has nothing left to decode.
|
||||
#[test]
|
||||
fn damaged_ciphertext_never_panics(alg in algorithms(), enc in prop::collection::vec(any::<u8>(), 0..64)) {
|
||||
let h = make(alg, "user", "owner");
|
||||
for cut in 0..=enc.len() {
|
||||
let broken = &enc[..cut];
|
||||
let out = h.decrypt_string(7, 3, broken);
|
||||
if alg == Algorithm::Rc4_40 || alg == Algorithm::Rc4_128 {
|
||||
prop_assert_eq!(out.len(), cut, "RC4 must be length-preserving ({:?})", alg);
|
||||
} else {
|
||||
let blocks = cut.saturating_sub(16) / 16;
|
||||
prop_assert!(out.len() <= blocks * 16, "{:?}: {} bytes of ciphertext yielded {} decrypted bytes", alg, cut, out.len());
|
||||
prop_assert!(blocks * 16 - out.len() <= 16, "{:?}: padding removed more than one block", alg);
|
||||
}
|
||||
}
|
||||
|
||||
// Flip every byte of a real ciphertext in turn: no position may crash the reader.
|
||||
let full = h.encrypt_string(7, 3, b"twenty-four bytes of PDF content");
|
||||
for i in 0..full.len() {
|
||||
let mut mutated = full.clone();
|
||||
mutated[i] ^= 0xFF;
|
||||
let _ = h.decrypt_string(7, 3, &mutated);
|
||||
}
|
||||
}
|
||||
|
||||
/// A crypt filter name decides whether content is encrypted at all: PDF 2.0 permits
|
||||
/// `/StmF /Identity` with attachments still encrypted, and a name that appears in neither `/CF`
|
||||
/// nor as `Identity` resolves to Identity rather than failing. The reported answer and the
|
||||
/// bytes must agree, at every length including empty (where AES still adds a whole block).
|
||||
/// `/EFF` is set here so embedded files and normal streams must take *different* paths: the
|
||||
/// mix-up that leaves an attachment in plain sight, or encrypts a stream its own filter says
|
||||
/// should not be. And sometimes `/CF` carries an entry *named* `Identity` that claims
|
||||
/// `/CFM /AESV2`: a producer writing nonsense. The name wins and the bytes stay plain, since
|
||||
/// encrypting what both sides agreed is plaintext would write a file other readers cannot open.
|
||||
#[test]
|
||||
fn crypt_filters_decide_what_gets_encrypted(
|
||||
data in payloads(),
|
||||
name in prop::sample::select(vec!["Identity", "StdCF", "Nope"]),
|
||||
bogus_identity_in_cf in any::<bool>(),
|
||||
) {
|
||||
let mut d = make(Algorithm::Aes256, "", "owner").dict().clone();
|
||||
d.str_f = name.as_bytes().to_vec();
|
||||
d.stm_f = b"Identity".to_vec();
|
||||
d.ef_f = b"StdCF".to_vec();
|
||||
if bogus_identity_in_cf {
|
||||
d.crypt_filters.push((b"Identity".to_vec(), Method::Aes256));
|
||||
}
|
||||
let h = SecurityHandler::open(d, ID, None).unwrap();
|
||||
|
||||
// Only `StdCF` really encrypts; `Identity` does not even when `/CF` defines it, and a name
|
||||
// in neither place resolves to Identity rather than failing.
|
||||
let strings_encrypted = name == "StdCF";
|
||||
prop_assert_eq!(h.encrypts_strings(), strings_encrypted, "/StrF /{} reported {}", name, strings_encrypted);
|
||||
|
||||
let enc = h.encrypt_string(3, 0, &data);
|
||||
if strings_encrypted {
|
||||
prop_assert_ne!(&enc, &data, "/StrF /{} left strings in plain sight", name);
|
||||
prop_assert!(enc.len() > data.len(), "AES always pads: {} bytes of ciphertext for {} plaintext", enc.len(), data.len());
|
||||
prop_assert_eq!(h.decrypt_string(3, 0, &enc), &data[..], "/StrF /{} round trip", name);
|
||||
} else {
|
||||
prop_assert_eq!(&enc, &data, "/StrF /{} must pass strings through", name);
|
||||
}
|
||||
|
||||
// A normal stream follows `/StmF /Identity` here, whatever the string filter says.
|
||||
prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Normal), &data[..], "normal streams follow /StmF Identity");
|
||||
// An embedded file follows `/EFF /StdCF`, so it is encrypted at every length — empty
|
||||
// included, where AES still emits a padded block — and comes back intact.
|
||||
let ef = h.encrypt_stream(3, 0, &data, StreamKind::EmbeddedFile);
|
||||
prop_assert_ne!(&ef, &data[..], "/EFF /StdCF left an embedded file in plain sight at {} bytes", data.len());
|
||||
prop_assert_eq!(h.decrypt_stream(3, 0, &ef, StreamKind::EmbeddedFile), &data[..], "embedded file round trip");
|
||||
// An unknown named filter is Identity, not an error; a known one uses its real method.
|
||||
prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"Missing")), &data[..], "an unknown crypt filter must not encrypt");
|
||||
prop_assert_ne!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"StdCF")), &data[..], "a filter named /StdCF must encrypt");
|
||||
}
|
||||
|
||||
/// Object keys are scoped to the object (Algorithm 1, §7.6.3.3): RC4 and AES-128 fold the
|
||||
/// object number and generation into the key, so another object must not read these bytes back.
|
||||
/// AES-256 is the documented exception — revisions 5 and 6 use the file key for every object.
|
||||
/// The comparisons that decrypt under a foreign number carry the weight: wrong keystream bytes
|
||||
/// match the plaintext only by a chance of one in 256 per byte. Checking that *encryption*
|
||||
/// differs would prove less, because our initial vector already covers `/Num`.
|
||||
#[test]
|
||||
fn object_keys_are_scoped_to_their_object(alg in prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128]), data in prop::collection::vec(any::<u8>(), 8..40)) {
|
||||
let h = make(alg, "user", "owner");
|
||||
|
||||
let enc = h.encrypt_string(100, 0, &data);
|
||||
prop_assert_eq!(h.decrypt_string(100, 0, &enc), &data[..], "{:?} reads its own object back", alg);
|
||||
prop_assert_ne!(h.decrypt_string(101, 0, &enc), &data[..], "{:?}: another object number must not decrypt these bytes", alg);
|
||||
prop_assert_ne!(h.decrypt_string(100, 1, &enc), &data[..], "{:?}: another generation must not decrypt these bytes", alg);
|
||||
|
||||
// Algorithm 1 folds the LOW THREE bytes of `/Num` into the key. Two directions are pinned:
|
||||
// numbers that differ inside those three bytes must separate (a fold to `[..2]` would let
|
||||
// object 0 and object 65_536 share a key, silently handing one object's plaintext to another),
|
||||
// and numbers that differ only ABOVE them must share it — the fold is three bytes by design,
|
||||
// so a widening to four would break this and nothing else in the crate notices.
|
||||
let low = h.encrypt_string(0, 0, &data);
|
||||
prop_assert_ne!(&h.decrypt_string(65_536, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide; 0 and 65_536 must not share a key", alg);
|
||||
prop_assert_ne!(&h.decrypt_string(1 << 23, 0, &low)[..], &data[..], "{:?}: the third byte of /Num must still reach the key", alg);
|
||||
prop_assert_eq!(&h.decrypt_string(1 << 24, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide, so a difference above bit 23 shares the key by design", alg);
|
||||
|
||||
let r6 = make(Algorithm::Aes256, "user", "owner");
|
||||
let enc6 = r6.encrypt_string(100, 0, &data);
|
||||
prop_assert_eq!(&r6.decrypt_string(101, 3, &enc6)[..], &data[..], "AES-256 uses the file key for every object (§7.6.3.3)");
|
||||
// ...which is exactly why the initial vector has to vary: revisions 5 and 6 reuse one key
|
||||
// for the whole file, so two objects holding the same bytes must still produce different
|
||||
// ciphertext. Identical bytes for a different `/Num` would be CBC initial-vector reuse —
|
||||
// the flaw that leaks structure in encrypted data.
|
||||
prop_assert_ne!(&r6.encrypt_string(101, 0, &data)[..], &enc6[..], "AES-256 reused the same initial vector for two objects");
|
||||
prop_assert_ne!(&r6.encrypt_string(100, 1, &data)[..], &enc6[..], "AES-256 reused the same initial vector across generations");
|
||||
}
|
||||
|
||||
/// A file created for `user` and `owner` opens for exactly the passwords whose effective bytes
|
||||
/// equal one of theirs, and reports the role that match earned — owner first, since viewers try
|
||||
/// `/O` before `/U`. Everything else is `WrongPassword`: never an opening, and no panic on odd
|
||||
/// Unicode. This is the rule #1185 became (bytes decide, so a soft hyphen alone is no password
|
||||
/// and U+FF21 is "A"), stated for all inputs rather than the three examples in `src/tests.rs`.
|
||||
/// It also pins #1762's upgrade path: a user session gains owner rights on the owner password
|
||||
/// and on nothing else.
|
||||
#[test]
|
||||
fn authentication_accepts_exactly_the_matching_passwords(
|
||||
alg in algorithms(),
|
||||
mut user in passwords(),
|
||||
owner in prop::sample::select(vec!["owner".to_string(), String::new(), "Owner\u{AD}".into(), "\u{FF21}".into()]),
|
||||
extra in passwords(),
|
||||
) {
|
||||
// `user` may have been grown past the 32-byte cut above; create with that spelling.
|
||||
// Grow the legacy password PAST the 32-byte cut BEFORE the file is built from it, so a
|
||||
// partner differing only beyond byte 32 is the same password to this file.
|
||||
if alg != Algorithm::Aes256 {
|
||||
user = format!("{user}{}", "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2));
|
||||
}
|
||||
|
||||
let h = make(alg, &user, &owner);
|
||||
let d = h.dict().clone();
|
||||
let user_bytes = effective_password(alg, &user);
|
||||
let owner_bytes = owner_target(alg, &user, &owner);
|
||||
|
||||
// A collision engineered by construction, not by luck. Revisions 2-4 cut the password at
|
||||
// 32 bytes (Algorithm 2), so any spelling that agrees on the first 32 is ONE password;
|
||||
// revisions 5-6 run SASLprep, which deletes U+00AD. Growing past byte 32 guarantees the
|
||||
// partner is a different string with identical bytes at every draw — earlier drafts here
|
||||
// claimed `(` alone did it, which is false: appending "(" to a short password DISPLACES the
|
||||
// padding (pad("a") is [61,28,BF..], pad("a(") is [61,28,28,BF..]), and this alphabet draws
|
||||
// at most 11 characters, so chance produced zero legacy collisions in 1,566 measured cases.
|
||||
|
||||
let mut suspects: Vec<String> = vec![user.clone(), owner.clone(), String::new()];
|
||||
// Near misses are PREPENDED: appending to a legacy password already past the cut would be
|
||||
// another collision rather than a refusal, which would silently thin the negative testing.
|
||||
let mut near_misses: Vec<String> = vec![format!("q{user}"), extra.clone()];
|
||||
if alg == Algorithm::Aes256 && !user.is_empty() {
|
||||
near_misses.push(format!("{user}q"));
|
||||
}
|
||||
|
||||
let collision_partner = if alg == Algorithm::Aes256 { format!("{user}\u{AD}") } else { format!("{user}Z") };
|
||||
prop_assert_ne!(&collision_partner[..], &user[..], "the collision partner must be a different string");
|
||||
prop_assert_eq!(effective_password(alg, &collision_partner), effective_password(alg, &user), "{:?}: {:?} must become the same bytes as {:?}", alg, collision_partner, user);
|
||||
|
||||
// The mistake itself, pinned so it cannot come back: a short legacy password plus "(" is NOT the
|
||||
// same password, because it shifts the padding by one byte instead of filling a slot.
|
||||
if alg != Algorithm::Aes256 {
|
||||
prop_assert_ne!(effective_password(alg, "a("), effective_password(alg, "a"), "appending ( to a short password displaces the padding; that is not a collision");
|
||||
prop_assert_eq!(effective_password(alg, &"a".repeat(31)), effective_password(alg, &format!("{}(", "a".repeat(31))), "at exactly 31 characters ( does land in the padding slot, and THAT is a collision");
|
||||
}
|
||||
|
||||
suspects.extend(near_misses);
|
||||
suspects.push(collision_partner.clone());
|
||||
suspects.dedup();
|
||||
|
||||
for suspect in &suspects {
|
||||
let want = expected_auth(alg, &user_bytes, &owner_bytes, suspect);
|
||||
match (want, SecurityHandler::open(d.clone(), ID, Some(suspect))) {
|
||||
(Some(role), Ok(got)) => {
|
||||
prop_assert_eq!(got.auth(), role, "{:?}: {:?} should open as {:?}", alg, suspect, role);
|
||||
prop_assert_eq!(got.file_key(), h.file_key(), "{:?}: whichever password opens the file, the key is the same", alg);
|
||||
}
|
||||
(Some(role), Err(e)) => prop_assert!(false, "{:?}: {:?} must open as {:?} but gave {:?}", alg, suspect, role, e),
|
||||
(None, Ok(got)) => prop_assert!(false, "{:?}: {:?} is not a password for this file, yet it opened as {:?}", alg, suspect, got.auth()),
|
||||
(None, Err(CryptError::WrongPassword)) => {}
|
||||
(None, Err(e)) => prop_assert!(false, "{:?}: {:?} gave {}, expected WrongPassword", alg, suspect, e),
|
||||
}
|
||||
|
||||
// The owner password upgrades a user session; nothing else does. When the two share
|
||||
// bytes, `create` gave /O the user's own bytes (revisions 2–4) or left it empty
|
||||
// (AES-256, #1185's sharp edge), so the open password *is* an owner password here.
|
||||
if let Ok(session) = SecurityHandler::open(d.clone(), ID, Some(&user)) {
|
||||
let is_owner_password = expected_auth(alg, &user_bytes, &owner_bytes, suspect) == Some(Auth::Owner);
|
||||
let upgrade = session.authenticate_owner(ID, suspect).map(|u| u.auth());
|
||||
if is_owner_password {
|
||||
prop_assert_eq!(upgrade, Ok(Auth::Owner), "{:?}: {:?} must unlock owner rights", alg, suspect);
|
||||
} else {
|
||||
prop_assert_eq!(upgrade, Err(CryptError::WrongPassword), "{:?}: {:?} must not grant owner rights", alg, suspect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `None` means no password was offered: the handler tries the empty string, and nothing
|
||||
// else (a file with a real open password never opens silently).
|
||||
let want_none = expected_auth(alg, &user_bytes, &owner_bytes, "");
|
||||
match (want_none, SecurityHandler::open(d.clone(), ID, None)) {
|
||||
(Some(role), Ok(got)) => prop_assert_eq!(got.auth(), role, "{:?}: no password opened as {:?}", alg, got.auth()),
|
||||
(None, Ok(got)) => prop_assert!(false, "{:?}: the empty password is not valid, yet no password opened as {:?}", alg, got.auth()),
|
||||
(Some(role), Err(e)) => prop_assert!(false, "{:?}: the empty password should open as {:?} but gave {:?}", alg, role, e),
|
||||
(None, Err(CryptError::WrongPassword)) => {}
|
||||
(None, Err(e)) => prop_assert!(false, "{:?}: no password gave {}, expected WrongPassword", alg, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The AES-CBC and RC4 primitives taken directly: a damaged file's `/Length` and crypt filters
|
||||
/// hand them keys and data of shapes `create` would never build, so they must invert each other
|
||||
/// for any key length (`Cipher::new` takes AES-256 at 32 bytes and zero-padded AES-128 below
|
||||
/// it, identically on both sides of the trip) and every payload length.
|
||||
#[test]
|
||||
fn primitives_are_invertible_for_any_key_and_length(
|
||||
key in prop::collection::vec(any::<u8>(), 1..40),
|
||||
data in payloads(),
|
||||
iv in prop::collection::vec(any::<u8>(), 16..24),
|
||||
) {
|
||||
let iv: [u8; 16] = *iv.as_slice().first_chunk::<16>().expect("the iv strategy draws at least 16 bytes; a silent zero fallback would hide a generator change");
|
||||
|
||||
let enc = aes_cbc_encrypt(&key, &iv, &data, true);
|
||||
prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &enc, true), &data[..], "padded AES round trip at {} bytes", data.len());
|
||||
prop_assert!(enc.len() >= 16 && enc.len().is_multiple_of(16), "PKCS#5 makes whole blocks: {} bytes for {} plaintext", enc.len(), data.len());
|
||||
|
||||
// Without padding, a partial block is zero-padded rather than refused, and extra trailing
|
||||
// bytes decrypt to more output instead of being dropped.
|
||||
let raw = aes_cbc_encrypt(&key, &iv, &data, false);
|
||||
prop_assert_eq!(raw.len(), data.len().div_ceil(16) * 16, "unpadded AES output is whole blocks");
|
||||
let mut longer = raw.clone();
|
||||
longer.extend_from_slice(b"0123456789abcdef");
|
||||
prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &longer, false).len(), raw.len() + 16, "a whole extra block decrypts to a whole extra block");
|
||||
|
||||
// RC4 undoes itself at every length; an empty key encrypts nothing, which is why the
|
||||
// handler never derives an empty object key.
|
||||
prop_assert_eq!(rc4(&key, &rc4(&key, &data)), &data[..], "RC4 round trip at {} bytes", data.len());
|
||||
prop_assert_eq!(rc4(&[], &data), &data[..], "an empty RC4 key passes bytes through");
|
||||
}
|
||||
|
||||
/// `/Perms` certifies the permission bits and `/EncryptMetadata`: decrypted with the file key it
|
||||
/// must hold `/P` little-endian, four set bytes, 'T' or 'F', and the `adb` sentinel (Algorithm 10).
|
||||
/// Nothing else in this crate reads it — `cos` writes it out and no code takes it back apart — so
|
||||
/// without this property a flipped metadata byte would tell every other viewer the wrong thing about
|
||||
/// our own file while all our tests stayed green. The older revisions we create emit no `/Perms`
|
||||
/// at all, which is recorded here as well so a change in what goes out shows up rather than
|
||||
/// shipping quietly.
|
||||
#[test]
|
||||
fn perms_value_agrees_with_the_permissions_it_certifies(permissions in any::<i32>(), encrypt_metadata in any::<bool>()) {
|
||||
let h = create(
|
||||
&NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x5e; 32] },
|
||||
ID,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prop_assert_eq!(h.dict().perms.len(), 16, "/Perms is one encrypted block");
|
||||
let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &h.dict().perms, false);
|
||||
|
||||
// Bits 1-2 clear and the reserved bits set (§7.6.4.2 Table 22) is what `/P` must say, and the
|
||||
// encrypted certificate has to repeat it byte for byte.
|
||||
let normalised = (permissions | !0xFFF | 0xC0) & !0x3;
|
||||
prop_assert_eq!(&open[..4], &normalised.to_le_bytes(), "/Perms must carry /P");
|
||||
prop_assert_eq!(h.dict().p, normalised, "the /P written must match the permission bits drawn");
|
||||
prop_assert_eq!(&open[4..8], &[0xFF; 4], "the four bytes following /P are set");
|
||||
prop_assert_eq!(open[8], if encrypt_metadata { b'T' } else { b'F' }, "/Perms must report /EncryptMetadata");
|
||||
prop_assert_eq!(&open[9..12], b"adb", "the adb sentinel must be present");
|
||||
|
||||
// A user session's answers come from those same bits, not from the owner shortcut.
|
||||
let as_user = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap();
|
||||
prop_assert_eq!(as_user.permissions().print(), normalised & 0b100 != 0, "/P bit 3 decides printing");
|
||||
prop_assert_eq!(as_user.permissions().copy(), normalised & 0b1_0000 != 0, "/P bit 5 decides copying");
|
||||
prop_assert!(!as_user.permissions().owner, "the user password must not report owner rights");
|
||||
|
||||
// Bytes 13-16 of /Perms are the non-repeating filler. Nothing reads them, so pinning that
|
||||
// they move with the seed is the only way to notice a producer that freezes them at zero.
|
||||
let other = create(
|
||||
&NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x11; 32] },
|
||||
ID,
|
||||
)
|
||||
.unwrap();
|
||||
let open_other = aes_cbc_decrypt(other.file_key(), &[0u8; 16], &other.dict().perms, false);
|
||||
prop_assert_ne!(&open[12..16], &open_other[12..16], "the four filler bytes of /Perms must follow the seed, not sit constant");
|
||||
|
||||
// Revisions below 5 carry no /Perms; pinned so the boundary of what we emit is explicit.
|
||||
for older in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
|
||||
let legacy = make(older, "user", "owner");
|
||||
prop_assert!(legacy.dict().perms.is_empty(), "{:?} must emit no /Perms", older);
|
||||
}
|
||||
}
|
||||
/// Revisions 4 mix four set bytes into every file key when `/EncryptMetadata` is false (Algorithm
|
||||
/// 2, step f), so the same password over the same `/ID` must produce a *different* key than when
|
||||
/// metadata is encrypted. Every other test in this crate builds `encrypt_metadata: true` only,
|
||||
/// which is exactly why that step is reachable but never walked: switching it off entirely changed
|
||||
/// nothing any test could see. This also records the rule that revisions below 4 always encrypt
|
||||
/// metadata whatever was asked, so the boundary between the two is named rather than assumed.
|
||||
#[test]
|
||||
fn metadata_flag_changes_the_key_it_claims_to_change(data in payloads()) {
|
||||
let build = |encrypt_metadata: bool| {
|
||||
create(&NewEncryption { algorithm: Algorithm::Aes128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata, seed: [0x5e; 32] }, ID).unwrap()
|
||||
};
|
||||
let (yes, no) = (build(true), build(false));
|
||||
|
||||
// Algorithm 2, step f: revisions 4 hash four set bytes into every key when metadata stays
|
||||
// plain, so two files with identical passwords and the same /ID cannot share a key.
|
||||
prop_assert_ne!(yes.file_key(), no.file_key(), "R4 must fold four set bytes into the key when /EncryptMetadata is false (Algorithm 2, step f)");
|
||||
prop_assert_eq!(no.dict().encrypt_metadata, false, "the flag must reach the dictionary");
|
||||
prop_assert!(!no.encrypts_metadata(), "/EncryptMetadata false must report itself honestly");
|
||||
prop_assert!(yes.encrypts_metadata(), "and true must report true");
|
||||
|
||||
// Both still open on the same password and round trip at every length: a derivation that
|
||||
// drifted would show up as an unreadable file rather than passing quietly.
|
||||
for h in [&yes, &no] {
|
||||
let open = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap();
|
||||
prop_assert_eq!(open.file_key(), h.file_key(), "the user password must still derive this file's key");
|
||||
let enc = h.encrypt_string(7, 0, &data);
|
||||
prop_assert_eq!(&open.decrypt_string(7, 0, &enc)[..], &data[..], "round trip at {} bytes", data.len());
|
||||
}
|
||||
|
||||
// And the two files must not read each other's content: different keys, different plaintext.
|
||||
if data.len() >= 8 {
|
||||
prop_assert_ne!(&yes.decrypt_string(7, 0, &no.encrypt_string(7, 0, &data))[..], &data[..], "a key built for plain metadata must not decrypt a file whose keys encrypt it");
|
||||
}
|
||||
|
||||
// Revisions below 4 always encrypt metadata, whatever the caller asked for.
|
||||
let legacy = create(&NewEncryption { algorithm: Algorithm::Rc4_128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata: false, seed: [0x5e; 32] }, ID).unwrap();
|
||||
prop_assert!(legacy.dict().encrypt_metadata && legacy.encrypts_metadata(), "V<4 always encrypts metadata, as create() documents");
|
||||
}
|
||||
|
||||
}
|
||||
+74
-19
@@ -11,10 +11,12 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi};
|
||||
use pdfcraft_fonts::{
|
||||
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi,
|
||||
};
|
||||
use unicode_bidi::{Level, ParagraphBidiInfo};
|
||||
|
||||
use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged};
|
||||
use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged};
|
||||
|
||||
const TAG: &str = "Added";
|
||||
|
||||
@@ -239,27 +241,28 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
let width = (t.rect[2] - t.rect[0]).max(t.size);
|
||||
let mut out = Vec::new();
|
||||
for para in t.text.split('\n') {
|
||||
let arabic = para.chars().any(is_arabic);
|
||||
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
|
||||
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
|
||||
let thai = para.chars().any(is_thai);
|
||||
if thai
|
||||
&& !arabic
|
||||
&& !rtl_script
|
||||
&& let Ok(lines) = thai_lines(t, para, width)
|
||||
{
|
||||
out.extend(lines.into_iter().map(|line| (line, false)));
|
||||
continue;
|
||||
}
|
||||
let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
|
||||
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
|
||||
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
|
||||
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
|
||||
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
|
||||
// of an em apart.
|
||||
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
|
||||
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
|
||||
let space = measure(" ");
|
||||
let (mut line, mut line_w) = (String::new(), 0.0);
|
||||
for word in para.split(' ') {
|
||||
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
let candidate_w =
|
||||
if arabic || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||||
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||||
if !line.is_empty() && candidate_w > width {
|
||||
out.push((std::mem::take(&mut line), rtl));
|
||||
line = word.to_string();
|
||||
@@ -313,9 +316,16 @@ fn is_arabic(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC)
|
||||
}
|
||||
|
||||
/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an
|
||||
/// item with any of them is drawn with the craft-fonts Hebrew face.
|
||||
fn is_hebrew(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F)
|
||||
}
|
||||
|
||||
/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn
|
||||
/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no
|
||||
/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open.
|
||||
/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't
|
||||
/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor
|
||||
/// keeps it open.
|
||||
pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
if t.text.chars().any(is_thai) {
|
||||
if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) {
|
||||
@@ -324,11 +334,13 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
|
||||
return first_undrawable(&rest);
|
||||
}
|
||||
// Arabic that nothing can draw: there's no face, or the face lacks it.
|
||||
if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) {
|
||||
// Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it.
|
||||
if let Some(c) =
|
||||
t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c)))
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) {
|
||||
if !shaped_by_a_face(t) {
|
||||
// Drawn line by line in the item's standard font (`\n` splits lines). Without the face
|
||||
// that is also how `draw` redraws an existing item, so it's checked that way.
|
||||
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
|
||||
@@ -343,6 +355,13 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with
|
||||
/// [`draw_shaped`]).
|
||||
fn shaped_by_a_face(t: &AddedText) -> bool {
|
||||
(pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic))
|
||||
|| (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew))
|
||||
}
|
||||
|
||||
/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped).
|
||||
fn shaped_arabic(c: char) -> bool {
|
||||
is_arabic(c) && !is_bidi_control(c)
|
||||
@@ -391,12 +410,16 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
|
||||
let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl);
|
||||
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
|
||||
let Some(text) = line.get(run) else { continue };
|
||||
// Arabic letters take the Arabic face; so do spaces and punctuation between them, which the
|
||||
// shaper then mirrors. Latin text (and what the face lacks) keeps the item's font.
|
||||
// Arabic and Hebrew letters take their face; so do spaces and punctuation between them
|
||||
// (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what
|
||||
// the faces lack) keeps the item's font.
|
||||
let mut segments: Vec<(Option<ShapedScript>, String)> = Vec::new();
|
||||
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
|
||||
let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew));
|
||||
let script = if is_thai(c) {
|
||||
Some(ShapedScript::Thai)
|
||||
} else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) {
|
||||
Some(ShapedScript::Hebrew)
|
||||
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
|
||||
Some(ShapedScript::Arabic)
|
||||
} else {
|
||||
@@ -414,6 +437,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
|
||||
pieces.push(if let Some(script) = script {
|
||||
let shaped = match script {
|
||||
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
|
||||
ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?,
|
||||
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
|
||||
};
|
||||
Piece::Shaped(shaped)
|
||||
@@ -450,6 +474,16 @@ fn arabic_error(e: GlyphError, text: &str) -> EditError {
|
||||
})
|
||||
}
|
||||
|
||||
fn hebrew_error(e: GlyphError, text: &str) -> EditError {
|
||||
EditError::Invalid(match e {
|
||||
GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \
|
||||
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)"
|
||||
.into(),
|
||||
GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""),
|
||||
GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"),
|
||||
})
|
||||
}
|
||||
|
||||
fn thai_error(e: GlyphError, text: &str) -> EditError {
|
||||
EditError::Invalid(match e {
|
||||
GlyphError::NoFont => {
|
||||
@@ -467,6 +501,11 @@ pub fn text_rect(t: &AddedText) -> [f64; 4] {
|
||||
[r[0], r[3] - h, r[2], r[3]]
|
||||
}
|
||||
|
||||
/// The widest explicit line before wrapping, measured with the PDF writing fonts.
|
||||
pub fn text_width(t: &AddedText) -> f64 {
|
||||
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn font_name(base: &str) -> String {
|
||||
format!("PCF{}", base.replace('-', ""))
|
||||
}
|
||||
@@ -479,7 +518,7 @@ const GLYPHS_PER_FONT: usize = 240;
|
||||
/// its pieces in drawing order and its width at the item's size.
|
||||
type LaidLine = (String, bool, Vec<Piece>, f64);
|
||||
|
||||
/// Draw a text item that has Arabic or Thai in it. Each line is laid out in display order; Arabic glyphs
|
||||
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
|
||||
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
|
||||
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
|
||||
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||||
@@ -524,6 +563,7 @@ fn draw_laid_shaped(
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
|
||||
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
|
||||
ShapedScript::Arabic => arabic_error(e, &c.text),
|
||||
ShapedScript::Hebrew => hebrew_error(e, &c.text),
|
||||
ShapedScript::Thai => thai_error(e, &c.text),
|
||||
})?);
|
||||
}
|
||||
@@ -663,6 +703,7 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
|
||||
Some(script) => script.font().map_or(
|
||||
match script {
|
||||
ShapedScript::Arabic => "Arabic",
|
||||
ShapedScript::Hebrew => "Hebrew",
|
||||
ShapedScript::Thai => "Thai",
|
||||
},
|
||||
|f| f.family,
|
||||
@@ -697,11 +738,21 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
|
||||
}
|
||||
|
||||
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
|
||||
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic face
|
||||
/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it.
|
||||
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or
|
||||
/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to
|
||||
/// move it.
|
||||
fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec<u8>, Dict), EditError> {
|
||||
let mut res = Dict::new();
|
||||
let mut out = format!("q {} {} {} {} {} {} cm\n", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(view[5])).into_bytes();
|
||||
let mut out = format!(
|
||||
"q {} {} {} {} {} {} cm\n",
|
||||
matrix_number(view[0]),
|
||||
matrix_number(view[1]),
|
||||
matrix_number(view[2]),
|
||||
matrix_number(view[3]),
|
||||
matrix_number(view[4]),
|
||||
matrix_number(view[5])
|
||||
)
|
||||
.into_bytes();
|
||||
match c {
|
||||
Content::Text(t) => {
|
||||
let base = t.family.base_font(t.bold, t.italic);
|
||||
@@ -715,7 +766,11 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
|
||||
}
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(name.clone().into_bytes(), Object::Dict(font));
|
||||
if t.text.chars().any(is_thai) || (t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing)) {
|
||||
let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing);
|
||||
if t.text.chars().any(is_thai)
|
||||
|| face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some())
|
||||
|| face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some())
|
||||
{
|
||||
draw_shaped(doc, t, taken, &mut fonts, &mut out)?;
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
out.extend_from_slice(b"Q\n");
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden
|
||||
//! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened
|
||||
//! comments take their pop-ups and replies with them; flattened widgets are removed from the
|
||||
//! form's field tree.
|
||||
//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays
|
||||
//! as it is, so no comment or field value is lost with nothing in its place.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -62,9 +63,15 @@ enum Which {
|
||||
fn selected(doc: &Document, d: &Dict, which: Which) -> bool {
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
match which {
|
||||
// Comments are what the comment list calls comments: links, pop-ups and the non-markup
|
||||
// multimedia and print-production annotations (3D, Screen, Movie, RichMedia,
|
||||
// PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087).
|
||||
Which::Comments { comments, fields } => {
|
||||
let widget = subtype == b"Widget";
|
||||
!matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments)
|
||||
if subtype == b"Widget" {
|
||||
fields
|
||||
} else {
|
||||
comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype))
|
||||
}
|
||||
}
|
||||
Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d),
|
||||
}
|
||||
@@ -154,6 +161,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
|
||||
.unwrap_or_default();
|
||||
let mut gone: HashSet<ObjRef> = HashSet::new();
|
||||
let mut gone_inline: HashSet<usize> = HashSet::new();
|
||||
// Selected, but nothing was drawn for it: it stays, pop-up and reply handling included.
|
||||
let mut undrawn: HashSet<ObjRef> = HashSet::new();
|
||||
// A viewer draws many comments from their dictionary even when the file stores no
|
||||
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
|
||||
// flattening keeps a comment that still has ink on screen instead of deleting it.
|
||||
@@ -199,9 +208,18 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
|
||||
}
|
||||
// A comment whose ink was not written into the page stays. Removing it would drop
|
||||
// the annotation (a cloudy square this builder cannot draw, for example) with nothing
|
||||
// left on the page. Widgets are form fields: flattening still removes them.
|
||||
if !widget && !drew {
|
||||
continue;
|
||||
// left on the page. A widget stays when it has no appearance to draw at all (none
|
||||
// stored, or none for its selected state): removing it would lose the field's value
|
||||
// with nothing in its place (#1794). One whose appearance draws nothing (an empty
|
||||
// stream) shows nothing anywhere, so flattening still removes it.
|
||||
if !drew {
|
||||
let no_appearance = appearance(doc, d).is_none();
|
||||
if no_appearance && let Some(r) = entry.as_ref() {
|
||||
undrawn.insert(r);
|
||||
}
|
||||
if !widget || no_appearance {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match entry.as_ref() {
|
||||
Some(r) => {
|
||||
@@ -218,9 +236,13 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
|
||||
if gone.is_empty() && gone_inline.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Pop-ups and replies of what was flattened go too.
|
||||
// Pop-ups and replies of what was flattened go too, except a reply that stayed because
|
||||
// it could not be drawn: its content would go with nothing in its place.
|
||||
for entry in &list {
|
||||
let Some(r) = entry.as_ref() else { continue };
|
||||
if undrawn.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| gone.contains(&t));
|
||||
|
||||
+252
-138
@@ -4,8 +4,8 @@
|
||||
//! or delete. Only the stream that draws it is rewritten, as a new object.
|
||||
|
||||
use kurbo::{BezPath, Point, Shape};
|
||||
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::EditError;
|
||||
@@ -30,6 +30,8 @@ pub struct PageImage {
|
||||
/// Pixel size; zero for vector/grouped Form artwork.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
|
||||
/// operators of the page's streams joined ([`Pieces`]).
|
||||
stream: usize,
|
||||
op: usize,
|
||||
/// CTM before `Do`, excluding a Form's own Matrix.
|
||||
@@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The page's streams joined into the one stream they are: an operator may span two of them.
|
||||
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
|
||||
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
|
||||
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
|
||||
}
|
||||
@@ -214,72 +221,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
|
||||
let p = page_of(doc, page)?;
|
||||
let xo = xobjects(doc, &p.dict);
|
||||
let mut out = Vec::new();
|
||||
// The graphics state carries over from one stream to the next.
|
||||
// The streams are one stream in pieces, so the graphics state and operators carry over.
|
||||
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
||||
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
|
||||
for (i, op) in parse(&data).ops.iter().enumerate() {
|
||||
state.apply(op)?;
|
||||
let ctm = state.placement.matrix;
|
||||
if op.is("Do") {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(resource) = xo.get(name) else { continue };
|
||||
let obj = doc.resolve(resource);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
|
||||
let (matrix, bounds) = if is_form {
|
||||
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
|
||||
v.as_array().and_then(|a| {
|
||||
if a.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let mut b = [0.0; 4];
|
||||
for (n, v) in b.iter_mut().zip(a) {
|
||||
*n = v.as_f64()?;
|
||||
}
|
||||
Some(b)
|
||||
})
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
Some(v) => {
|
||||
let v = doc.resolve(v);
|
||||
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
|
||||
m
|
||||
let pieces = joined(&streams(doc, &p.dict));
|
||||
for (i, op) in pieces.parse().ops.iter().enumerate() {
|
||||
state.apply(op)?;
|
||||
let ctm = state.placement.matrix;
|
||||
if op.is("Do") {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(resource) = xo.get(name) else { continue };
|
||||
let obj = doc.resolve(resource);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
|
||||
let (matrix, bounds) = if is_form {
|
||||
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
|
||||
v.as_array().and_then(|a| {
|
||||
if a.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
None => Matrix::IDENTITY,
|
||||
};
|
||||
(matrix.then(&ctm), bounds)
|
||||
} else if s.dict.name(b"Subtype") == Some(b"Image") {
|
||||
(ctm, [0.0, 0.0, 1.0, 1.0])
|
||||
} else {
|
||||
let mut b = [0.0; 4];
|
||||
for (n, v) in b.iter_mut().zip(a) {
|
||||
*n = v.as_f64()?;
|
||||
}
|
||||
Some(b)
|
||||
})
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let rect = matrix.bbox(bounds);
|
||||
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|
||||
|| bounds[2] <= bounds[0]
|
||||
|| bounds[3] <= bounds[1]
|
||||
|| rect[2] <= rect[0]
|
||||
|| rect[3] <= rect[1]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect,
|
||||
visible_rect: intersection(rect, state.placement.clip),
|
||||
clip_rect: Some(state.placement.clip),
|
||||
matrix: matrix.0,
|
||||
name: String::from_utf8_lossy(name).into_owned(),
|
||||
object: resource.as_ref(),
|
||||
is_form,
|
||||
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
stream: si,
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
clips: state.placement.clips.clone(),
|
||||
});
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
Some(v) => {
|
||||
let v = doc.resolve(v);
|
||||
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
|
||||
m
|
||||
}
|
||||
None => Matrix::IDENTITY,
|
||||
};
|
||||
(matrix.then(&ctm), bounds)
|
||||
} else if s.dict.name(b"Subtype") == Some(b"Image") {
|
||||
(ctm, [0.0, 0.0, 1.0, 1.0])
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let rect = matrix.bbox(bounds);
|
||||
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|
||||
|| bounds[2] <= bounds[0]
|
||||
|| bounds[3] <= bounds[1]
|
||||
|| rect[2] <= rect[0]
|
||||
|| rect[3] <= rect[1]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect,
|
||||
visible_rect: intersection(rect, state.placement.clip),
|
||||
clip_rect: Some(state.placement.clip),
|
||||
matrix: matrix.0,
|
||||
name: String::from_utf8_lossy(name).into_owned(),
|
||||
object: resource.as_ref(),
|
||||
is_form,
|
||||
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
stream: pieces.piece_of(op.span.start),
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
clips: state.placement.clips.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
@@ -298,15 +304,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
}
|
||||
fn walk(
|
||||
doc: &Document,
|
||||
data: &[u8],
|
||||
ops: &[Op],
|
||||
resources: &Dict,
|
||||
st: &mut ImageState,
|
||||
stream: usize,
|
||||
stream: &dyn Fn(&Op) -> usize,
|
||||
forms: &mut Forms,
|
||||
out: &mut Vec<PageImage>,
|
||||
) -> Result<(), EditError> {
|
||||
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
for (i, op) in parse(data).ops.iter().enumerate() {
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
st.apply(op)?;
|
||||
let ctm = st.placement.matrix;
|
||||
if op.is("Do") {
|
||||
@@ -325,7 +331,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
);
|
||||
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
|
||||
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
|
||||
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
|
||||
let piece = stream(op);
|
||||
walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?;
|
||||
if form.obj.is_some() {
|
||||
forms.path.pop();
|
||||
}
|
||||
@@ -347,7 +354,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
is_form: false,
|
||||
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
|
||||
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
|
||||
stream,
|
||||
stream: stream(op),
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
clips: st.placement.clips.clone(),
|
||||
@@ -361,9 +368,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
let mut out = Vec::new();
|
||||
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
||||
let mut forms = Forms { path: Vec::new(), visits: 0 };
|
||||
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
|
||||
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
|
||||
}
|
||||
let pieces = joined(&streams(doc, &p.dict));
|
||||
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -372,6 +378,10 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
pub enum ImageChange {
|
||||
/// Apply a user-space transform after its placement (move, resize, rotate, flip).
|
||||
Transform([f64; 6]),
|
||||
/// Show only this part of it (a box in user space): a clipping path around the drawing,
|
||||
/// inside its own scope so a later move or resize carries it along. The pixels stay where
|
||||
/// they are; nothing is scaled.
|
||||
Crop([f64; 4]),
|
||||
/// Draw this image XObject instead, in the same place.
|
||||
Replace(ObjRef),
|
||||
Delete,
|
||||
@@ -420,6 +430,19 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> {
|
||||
Some((at, Matrix(ops.get(at + 3)?.nums::<6>()?)))
|
||||
}
|
||||
|
||||
/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced
|
||||
/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same
|
||||
/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each.
|
||||
fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> {
|
||||
let at = start.checked_sub(1)?;
|
||||
let cm = ops.get(at)?;
|
||||
if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let m = Matrix(cm.nums::<6>()?);
|
||||
m.0.iter().all(|v| v.is_finite()).then_some((at, m))
|
||||
}
|
||||
|
||||
/// The `cm` to put before the clip so that user-space transform `t` also moves the clip:
|
||||
/// the clip is drawn in the CTM before the image's own `cm` (`cm` · that = `draw`).
|
||||
fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option<Matrix> {
|
||||
@@ -433,9 +456,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
|
||||
let p = page_of(doc, page)?;
|
||||
let all = streams(doc, &p.dict);
|
||||
let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
|
||||
let mut ops = parse(&data).ops;
|
||||
if ops.get(img.op).is_none() {
|
||||
let pieces = joined(&all);
|
||||
let ops = pieces.parse().ops;
|
||||
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
|
||||
return Err(EditError::Invalid("the image operation is missing".into()));
|
||||
}
|
||||
let mut resources = None;
|
||||
@@ -444,6 +467,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
ImageChange::Transform(t) => {
|
||||
return transform_image(doc, &p, &all, &img, *t);
|
||||
}
|
||||
ImageChange::Crop(keep) => {
|
||||
return crop_image(doc, &p, &all, &img, *keep);
|
||||
}
|
||||
ImageChange::Replace(r) => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Invalid(
|
||||
@@ -459,16 +485,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
vec![Op::new("Do", vec![Object::name(&name)])]
|
||||
}
|
||||
};
|
||||
ops.splice(img.op..=img.op, replacement);
|
||||
let mut dict = match &*doc.resolve(&obj) {
|
||||
Object::Stream(s) => s.dict.clone(),
|
||||
_ => Dict::new(),
|
||||
};
|
||||
dict.remove(b"Length");
|
||||
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops))));
|
||||
let contents: Vec<Object> = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect();
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
|
||||
let contents = crate::text::rewritten_contents(doc, &all, changed);
|
||||
doc.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
|
||||
d.set(b"Contents".to_vec(), contents);
|
||||
if let Some(res) = resources {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
@@ -579,22 +599,12 @@ fn transform_image(
|
||||
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
|
||||
}
|
||||
let mut offset = 0_usize;
|
||||
for (_, data) in streams.iter().take(image.stream) {
|
||||
offset = offset
|
||||
.checked_add(data.len())
|
||||
.and_then(|n| n.checked_add(1))
|
||||
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
}
|
||||
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
|
||||
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
|
||||
let pieces = joined(streams);
|
||||
let ops = pieces.parse().ops;
|
||||
let target = ops
|
||||
.iter()
|
||||
.position(|op| op.span.start == span && op.is("Do"))
|
||||
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
|
||||
let target = image.op;
|
||||
if !ops.get(target).is_some_and(|op| op.is("Do")) {
|
||||
return Err(EditError::Invalid("the image operation could not be located".into()));
|
||||
}
|
||||
// A private path crop moves with its image regardless of intervening state operators.
|
||||
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
|
||||
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
@@ -603,6 +613,20 @@ fn transform_image(
|
||||
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image transform is too large".into()));
|
||||
}
|
||||
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
|
||||
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
|
||||
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
|
||||
let folded = local.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image transform is too large".into()));
|
||||
}
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) }
|
||||
});
|
||||
let contents = crate::text::rewritten_contents(doc, streams, changed);
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
return Ok(());
|
||||
}
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if index == start {
|
||||
let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())];
|
||||
@@ -625,6 +649,76 @@ fn transform_image(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Crop: clip the image's drawing to `keep` (user space). The clip goes just inside the scope
|
||||
/// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in
|
||||
/// that scope's coordinates as the box's four corners, so a turned image crops to an upright
|
||||
/// box too and a later transform of the scope moves the crop with the pixels.
|
||||
fn crop_image(
|
||||
doc: &mut Document,
|
||||
page: &pdfcraft_model::Page,
|
||||
streams: &[(Object, Vec<u8>)],
|
||||
image: &PageImage,
|
||||
keep: [f64; 4],
|
||||
) -> Result<(), EditError> {
|
||||
if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into()));
|
||||
}
|
||||
let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])];
|
||||
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
|
||||
return Err(EditError::Invalid("the crop box has no area".into()));
|
||||
}
|
||||
let pieces = joined(streams);
|
||||
let ops = pieces.parse().ops;
|
||||
let target = image.op;
|
||||
if !ops.get(target).is_some_and(|op| op.is("Do")) {
|
||||
return Err(EditError::Invalid("the image operation could not be located".into()));
|
||||
}
|
||||
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
|
||||
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
|
||||
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the crop box is too large for the image's placement".into()));
|
||||
}
|
||||
let clip: Vec<Op> = corners
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)]))
|
||||
.chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])])
|
||||
.collect();
|
||||
// The image's own earlier crop (a clipping path right after the scope's `q`) gives way to
|
||||
// the new one, so a crop can grow again as well as shrink.
|
||||
let old_clip: Option<std::ops::Range<usize>> = (start != end)
|
||||
.then(|| {
|
||||
let mut k = start + 1;
|
||||
while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) {
|
||||
k += 1;
|
||||
}
|
||||
(k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n")))
|
||||
.then_some(start + 1..k + 2)
|
||||
})
|
||||
.flatten();
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if start == end && index == target {
|
||||
// No scope of its own yet: one around the drawing, with the crop inside.
|
||||
let mut out = vec![Op::new("q", vec![])];
|
||||
out.extend(clip.iter().cloned());
|
||||
out.extend(ops.get(index).cloned());
|
||||
out.push(Op::new("Q", vec![]));
|
||||
(out, false)
|
||||
} else if start != end && index == start + 1 {
|
||||
// Inside the scope's `q`, before whatever it draws (in place of its old crop).
|
||||
(clip.clone(), old_clip.is_none())
|
||||
} else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) {
|
||||
(Vec::new(), false)
|
||||
} else {
|
||||
(Vec::new(), true)
|
||||
}
|
||||
});
|
||||
let contents = crate::text::rewritten_contents(doc, streams, changed);
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
|
||||
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
|
||||
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
|
||||
@@ -644,52 +738,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
}
|
||||
plans.insert((image.stream, image.op), (movement, placement, offset));
|
||||
plans.insert(image.op, (movement, placement, offset));
|
||||
}
|
||||
let mut data = Vec::with_capacity(all.len());
|
||||
let pieces = joined(&all);
|
||||
let ops = pieces.parse().ops;
|
||||
let mut inside_text = false;
|
||||
for (stream, (_, bytes)) in all.iter().enumerate() {
|
||||
let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]);
|
||||
let ops = pieces.parse().ops;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
if op.is("BT") {
|
||||
inside_text = true;
|
||||
} else if op.is("ET") {
|
||||
inside_text = false;
|
||||
} else if inside_text && plans.contains_key(&(stream, index)) {
|
||||
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
|
||||
}
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
if op.is("BT") {
|
||||
inside_text = true;
|
||||
} else if op.is("ET") {
|
||||
inside_text = false;
|
||||
} else if inside_text && plans.contains_key(&index) {
|
||||
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
|
||||
}
|
||||
// Images alone in their own `q re W n cm Do Q` take the clip with them.
|
||||
let mut clips = std::collections::BTreeMap::new();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue };
|
||||
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
|
||||
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
|
||||
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
};
|
||||
plans.remove(&(stream, index));
|
||||
clips.insert((stream, at), c);
|
||||
}
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if let Some(c) = clips.remove(&(stream, index)) {
|
||||
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true);
|
||||
}
|
||||
let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
|
||||
let original = ops.get(index).cloned().into_iter();
|
||||
// Local translations can be tiny under a large CTM. Preserve their precision
|
||||
// rather than rounding them to zero or amplifying an error on the page.
|
||||
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
|
||||
out.extend(original);
|
||||
out.push(Op::new("Q", vec![]));
|
||||
(out, false)
|
||||
});
|
||||
data.push(changed.into_iter().next().unwrap_or_default());
|
||||
}
|
||||
if !plans.is_empty() {
|
||||
// Images alone in their own `q re W n cm Do Q` take the clip with them.
|
||||
let mut clips = std::collections::BTreeMap::new();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(_, placement, offset)) = plans.get(&index) else { continue };
|
||||
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
|
||||
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
|
||||
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
};
|
||||
plans.remove(&index);
|
||||
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
|
||||
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
|
||||
let folded = at
|
||||
.checked_sub(1)
|
||||
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
|
||||
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
|
||||
match folded {
|
||||
Some((i, m)) => clips.insert(i, (m, false)),
|
||||
None => clips.insert(at, (c, true)),
|
||||
};
|
||||
}
|
||||
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
|
||||
let mut folds = std::collections::BTreeMap::new();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
|
||||
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
|
||||
let folded = movement.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
}
|
||||
plans.remove(&index);
|
||||
folds.insert(at, folded);
|
||||
}
|
||||
if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) {
|
||||
return Err(EditError::Invalid("a selected image operation is missing".into()));
|
||||
}
|
||||
let data = crate::text::splice(&pieces, &ops, |index| {
|
||||
if let Some((c, keep)) = clips.remove(&index) {
|
||||
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
|
||||
}
|
||||
if let Some(m) = folds.remove(&index) {
|
||||
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
|
||||
}
|
||||
let Some((matrix, _, _)) = plans.remove(&index) else { return (Vec::new(), true) };
|
||||
let original = ops.get(index).cloned().into_iter();
|
||||
// Local translations can be tiny under a large CTM. Preserve their precision
|
||||
// rather than rounding them to zero or amplifying an error on the page.
|
||||
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
|
||||
out.extend(original);
|
||||
out.push(Op::new("Q", vec![]));
|
||||
(out, false)
|
||||
});
|
||||
let contents = crate::text::rewritten_contents(doc, &all, data);
|
||||
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(())
|
||||
|
||||
+274
-41
@@ -8,7 +8,8 @@
|
||||
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
|
||||
//! Remove find them again without decompressing page content. When
|
||||
//! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams)
|
||||
//! so its graphics state can't leak into the mark.
|
||||
//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished
|
||||
//! marked-content sequences before the new mark.
|
||||
//!
|
||||
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
|
||||
|
||||
@@ -73,6 +74,17 @@ impl Default for HeaderFooter {
|
||||
}
|
||||
}
|
||||
|
||||
impl HeaderFooter {
|
||||
/// The first character [`add_header_footer`] would refuse, because the standard fonts can't
|
||||
/// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the
|
||||
/// template), so checking the templates, split as `expand`'s output is, covers every page
|
||||
/// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview
|
||||
/// never offers text that applying refuses (#2013).
|
||||
pub fn first_undrawable(&self) -> Option<char> {
|
||||
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in
|
||||
/// the document, an image (drawn into a unit square) or a form (a page of a PDF).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -104,6 +116,17 @@ pub struct Watermark {
|
||||
pub offset: [f64; 2],
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The first character of a text watermark [`add_watermark`] would refuse, because the
|
||||
/// standard fonts can't draw it (#125). A picture watermark draws no text.
|
||||
pub fn first_undrawable(&self) -> Option<char> {
|
||||
match self.source {
|
||||
Some(_) => None,
|
||||
None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -172,6 +195,13 @@ pub struct Context {
|
||||
pub date: (i64, u32, u32),
|
||||
}
|
||||
|
||||
// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision.
|
||||
fn matrix_number(value: f64) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Real(value), &mut bytes);
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn n(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
@@ -298,7 +328,41 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio
|
||||
}
|
||||
|
||||
const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
|
||||
const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
|
||||
/// Starts with an end of line, so a comment left open at the end of the content before it
|
||||
/// can't swallow the `Q`. Older files have the close without it.
|
||||
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
|
||||
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
|
||||
|
||||
/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing
|
||||
/// graphics closures. Legacy closes without the leading newline remain valid on their own.
|
||||
fn is_wrap_close(bytes: &[u8]) -> bool {
|
||||
let mut rest = bytes;
|
||||
while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) {
|
||||
rest = shorter;
|
||||
}
|
||||
let legacy = rest.len() == bytes.len();
|
||||
loop {
|
||||
if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) {
|
||||
return true;
|
||||
}
|
||||
match rest.strip_prefix(b"\nQ".as_slice()) {
|
||||
Some(shorter) => rest = shorter,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)`
|
||||
/// for a close.
|
||||
fn wrapper_role(doc: &Document, o: &Object) -> Option<bool> {
|
||||
if stream_tag(doc, o).as_deref() != Some("Wrap") {
|
||||
return None;
|
||||
}
|
||||
let Object::Stream(stream) = &*doc.resolve(o) else { return None };
|
||||
// Wrappers are a few bytes; one that decodes to more isn't PdfCraft's.
|
||||
let bytes = stream.decoded_within(64 * 1024).ok()?;
|
||||
if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
|
||||
@@ -321,27 +385,165 @@ fn tagged(tag: &str) -> Dict {
|
||||
d
|
||||
}
|
||||
|
||||
/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags,
|
||||
/// which another writer may keep, copy or forge).
|
||||
enum Isolation {
|
||||
/// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths.
|
||||
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
|
||||
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
|
||||
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
|
||||
/// `marked`: unfinished marked-content sequences to close before a new mark.
|
||||
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)>, marked: usize },
|
||||
/// The content could not be read that precisely (it doesn't decode, is too large, or has
|
||||
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
|
||||
/// the count of `q` left open at the end, when known. `marked` counts known unfinished
|
||||
/// marked-content sequences independently of graphics malformation, or zero when unknown.
|
||||
Unknown { unclosed: usize, marked: usize },
|
||||
}
|
||||
|
||||
const ISOLATION_MAX_STREAMS: usize = 100_000;
|
||||
const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
const ISOLATION_MAX_OPS: usize = 1_000_000;
|
||||
const ISOLATION_MAX_DEPTH: usize = 10_000;
|
||||
|
||||
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
|
||||
let unknown = Isolation::Unknown { unclosed: 0, marked: 0 };
|
||||
if list.len() > ISOLATION_MAX_STREAMS {
|
||||
return unknown;
|
||||
}
|
||||
let mut remaining = ISOLATION_MAX_BYTES;
|
||||
let mut data = Vec::with_capacity(list.len());
|
||||
for object in list {
|
||||
let resolved = doc.resolve(object);
|
||||
let Object::Stream(stream) = &*resolved else { return unknown };
|
||||
let Ok(bytes) = stream.decoded_within(remaining) else { return unknown };
|
||||
let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown };
|
||||
remaining = left;
|
||||
data.push(bytes);
|
||||
}
|
||||
let pieces = pdfcraft_content::Pieces::join(&data);
|
||||
let parsed = pieces.parse();
|
||||
if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS {
|
||||
return unknown;
|
||||
}
|
||||
let edges = pieces.comment_edges(&parsed);
|
||||
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let (mut marked, mut marked_known) = (0usize, true);
|
||||
let mut split = 0;
|
||||
let mut previous_last = None;
|
||||
let mut wrappers = Vec::new();
|
||||
for op in &parsed.ops {
|
||||
let (first, last) = pieces.pieces_of(op);
|
||||
// A wrapper can only go in before a whole stream, outside graphics and marked-content
|
||||
// scopes, between operators, where no comment would swallow the wrapper's `q`.
|
||||
if stack.is_empty()
|
||||
&& marked_known
|
||||
&& marked == 0
|
||||
&& !text_open
|
||||
&& !path_open
|
||||
&& !dirty
|
||||
&& previous_last.is_none_or(|previous| first > previous)
|
||||
&& !edges.get(first).is_some_and(|edge| edge.0)
|
||||
{
|
||||
split = first;
|
||||
}
|
||||
previous_last = Some(last);
|
||||
// Match the reader's tolerant operands: BMC uses the last name, BDC the last
|
||||
// name/object pair, and EMC ignores surplus operands. Invalid begins are ignored.
|
||||
let begin_marked = match op.op.as_slice() {
|
||||
b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))),
|
||||
b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))),
|
||||
_ => false,
|
||||
};
|
||||
if marked_known {
|
||||
if begin_marked {
|
||||
if marked < ISOLATION_MAX_DEPTH {
|
||||
marked += 1;
|
||||
} else {
|
||||
// Once the budget is exceeded, later EMCs cannot restore a known depth.
|
||||
marked_known = false;
|
||||
marked = 0;
|
||||
}
|
||||
} else if op.is("EMC") {
|
||||
marked = marked.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"BT" if !text_open => text_open = true,
|
||||
b"ET" if text_open => text_open = false,
|
||||
b"BT" | b"ET" => malformed = true,
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true,
|
||||
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false,
|
||||
_ => {}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first),
|
||||
b"Q" if op.operands.is_empty() => match stack.pop() {
|
||||
Some(open) => {
|
||||
// Only a standalone PdfCraft wrapper pair may later be removed again.
|
||||
let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
||||
if wrap(open)
|
||||
&& wrap(first)
|
||||
&& data.get(open).is_some_and(|d| d == WRAP_OPEN)
|
||||
&& data.get(first).is_some_and(|d| is_wrap_close(d))
|
||||
{
|
||||
wrappers.push((open, first));
|
||||
}
|
||||
}
|
||||
None => malformed = true,
|
||||
},
|
||||
b"q" | b"Q" => malformed = true,
|
||||
_ if stack.is_empty() => dirty = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if malformed || text_open || path_open || !stack.is_empty() || !marked_known {
|
||||
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
|
||||
}
|
||||
let comment_open = edges.last().is_some_and(|edge| edge.1);
|
||||
Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
|
||||
}
|
||||
|
||||
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
|
||||
/// original content in q/Q).
|
||||
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
place_tagged(doc, page, kind.tag(), content, behind)
|
||||
}
|
||||
|
||||
/// Put content on a page, tagged `tag` (see [`place`]).
|
||||
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
|
||||
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
|
||||
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
|
||||
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
|
||||
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
|
||||
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
let mut list = contents(doc, page)?;
|
||||
let wrap = if behind {
|
||||
None
|
||||
} else {
|
||||
match isolation(doc, &list) {
|
||||
Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)),
|
||||
// Content that can't be read precisely is wrapped whole, as before: after the
|
||||
// behind-marks at the start. A `q` it leaves open is closed at its end, where
|
||||
// closing it changes nothing already drawn.
|
||||
Isolation::Unknown { unclosed, marked } => {
|
||||
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
|
||||
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
|
||||
(split < list.len()).then_some((split, unclosed, marked))
|
||||
}
|
||||
}
|
||||
};
|
||||
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
|
||||
if behind {
|
||||
list.insert(0, mark);
|
||||
} else {
|
||||
let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
||||
let original = list.iter().any(|o| stream_tag(doc, o).is_none());
|
||||
if !wrapped && original {
|
||||
if let Some((split, unclosed, marked)) = wrap {
|
||||
let mut close_bytes = b"\nEMC".repeat(marked);
|
||||
close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed));
|
||||
close_bytes.extend_from_slice(WRAP_CLOSE);
|
||||
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
|
||||
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec()))));
|
||||
// Behind-marks stay outside the wrapper.
|
||||
let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len());
|
||||
list.insert(split, open);
|
||||
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
|
||||
list.insert(split.min(list.len()), open);
|
||||
list.push(close);
|
||||
}
|
||||
list.push(mark);
|
||||
@@ -365,27 +567,17 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
|
||||
format!(
|
||||
"q\n/Artifact <</Type /Pagination /Subtype /{subtype} /PCMark /{}>> BDC\n{} {} {} {} {} {} cm\n",
|
||||
kind.tag(),
|
||||
n(matrix[0]),
|
||||
n(matrix[1]),
|
||||
n(matrix[2]),
|
||||
n(matrix[3]),
|
||||
n(matrix[4]),
|
||||
n(matrix[5])
|
||||
matrix_number(matrix[0]),
|
||||
matrix_number(matrix[1]),
|
||||
matrix_number(matrix[2]),
|
||||
matrix_number(matrix[3]),
|
||||
matrix_number(matrix[4]),
|
||||
matrix_number(matrix[5])
|
||||
)
|
||||
}
|
||||
|
||||
const END: &str = "EMC\nQ\n";
|
||||
|
||||
/// Refuses page text the standard-14 fonts (WinAnsiEncoding) can't draw: written anyway, each
|
||||
/// such character would become a `?` on the page (#125). `lines` must split the text the way the
|
||||
/// caller draws it, so a `\r` that would be drawn is refused too.
|
||||
pub(crate) fn drawable<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<(), EditError> {
|
||||
match lines.into_iter().find_map(pdfcraft_fonts::first_non_win_ansi) {
|
||||
Some(c) => Err(undrawable(c)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The refusal for page text with `c`, which nothing here can draw.
|
||||
pub(crate) fn undrawable(c: char) -> EditError {
|
||||
EditError::Invalid(undrawable_message(c))
|
||||
@@ -411,9 +603,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
|
||||
if hf.text.iter().all(|t| t.trim().is_empty()) {
|
||||
return Err(EditError::Invalid("type the header or footer text first".into()));
|
||||
}
|
||||
// Tokens only add ASCII (Bates prefixes and suffixes are part of the template), so checking
|
||||
// the templates, split as `expand`'s output is, covers every page.
|
||||
hf.text.iter().try_for_each(|t| drawable(t.lines()))?;
|
||||
if let Some(c) = hf.first_undrawable() {
|
||||
return Err(undrawable(c));
|
||||
}
|
||||
if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) {
|
||||
return Err(EditError::Invalid("invalid font size or margins".into()));
|
||||
}
|
||||
@@ -431,18 +623,26 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
|
||||
u32::MAX
|
||||
)));
|
||||
}
|
||||
// The same goes for Bates numbers: the last selected page must still fit in a u64.
|
||||
let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1);
|
||||
if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})",
|
||||
pages.len(),
|
||||
u64::MAX
|
||||
)));
|
||||
}
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
|
||||
}
|
||||
let all = page_list(doc);
|
||||
let count = all.len();
|
||||
let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1);
|
||||
for (k, &i) in pages.iter().enumerate() {
|
||||
let page = &all[i];
|
||||
let (w, h) = page.display_size(doc);
|
||||
let number =
|
||||
u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?;
|
||||
let bates = bates0 + k as u64;
|
||||
let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?;
|
||||
let size = hf.font_size;
|
||||
let [top, bottom, left, right] = hf.margins;
|
||||
let mut body: Vec<u8> = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
|
||||
@@ -491,8 +691,8 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
|
||||
if text.is_empty() && wm.source.is_none() {
|
||||
return Err(EditError::Invalid("type the watermark text first".into()));
|
||||
}
|
||||
if wm.source.is_none() {
|
||||
drawable(text.lines())?;
|
||||
if let Some(c) = wm.first_undrawable() {
|
||||
return Err(undrawable(c));
|
||||
}
|
||||
if !(wm.opacity.is_finite()
|
||||
&& wm.rotation.is_finite()
|
||||
@@ -586,8 +786,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on
|
||||
/// top of a page, the q/Q wrapper goes too, restoring the original content list.
|
||||
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
|
||||
/// nothing after them go too, restoring the original content list; a wrapper that content
|
||||
/// after it still relies on (another tool's appended stream) stays.
|
||||
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
@@ -598,12 +799,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu
|
||||
let before = list.len();
|
||||
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
|
||||
removed += before - kept.len();
|
||||
// Marks on top come after the closing wrapper; without any, the wrapper has no purpose.
|
||||
let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap");
|
||||
let close = kept.iter().rposition(&is_wrap);
|
||||
let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap")));
|
||||
if !marks_on_top {
|
||||
kept.retain(|o| !is_wrap(o));
|
||||
// Strip only wrapper pairs whose close ends the list (after stripping the pairs after
|
||||
// it): nothing comes after them to need the state they restore. Pairs come from the
|
||||
// operators; for content that can't be read that precisely, from the wrapper streams
|
||||
// alone, so updating a mark there doesn't nest another wrapper each time.
|
||||
if before != kept.len() {
|
||||
let wrappers = match isolation(doc, &kept) {
|
||||
Isolation::Clean { wrappers, .. } => wrappers,
|
||||
Isolation::Unknown { .. } => {
|
||||
let mut opens = Vec::new();
|
||||
let mut pairs = Vec::new();
|
||||
for (i, o) in kept.iter().enumerate() {
|
||||
match wrapper_role(doc, o) {
|
||||
Some(true) => opens.push(i),
|
||||
Some(false) => pairs.extend(opens.pop().map(|open| (open, i))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
};
|
||||
let pairs: std::collections::HashMap<usize, usize> = wrappers.into_iter().map(|(open, close)| (close, open)).collect();
|
||||
let mut discarded = std::collections::HashSet::new();
|
||||
let mut end = kept.len();
|
||||
while let Some(close) = end.checked_sub(1) {
|
||||
if discarded.contains(&close) {
|
||||
end = close;
|
||||
} else if let Some(&open) = pairs.get(&close) {
|
||||
discarded.insert(open);
|
||||
discarded.insert(close);
|
||||
end = close;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect();
|
||||
}
|
||||
if before != kept.len() {
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
|
||||
@@ -626,6 +856,7 @@ pub mod images;
|
||||
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre};
|
||||
pub mod bidi;
|
||||
pub mod indic;
|
||||
mod linebreak;
|
||||
pub mod text;
|
||||
pub use text::{
|
||||
BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks,
|
||||
@@ -637,5 +868,7 @@ pub use added::{
|
||||
update_content,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod marked_isolation_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Where a row of rewrapped text may end besides a space: between two characters when either is
|
||||
//! written without spaces between words (Chinese and Japanese ideographs and kana), following the
|
||||
//! simple cases of Unicode line breaking (UAX #14, class ID). The row must not then begin with a
|
||||
//! character that closes or follows (closing brackets and quotes, commas and full stops, small
|
||||
//! kana, the prolonged sound mark, iteration marks) or end with one that opens (opening brackets
|
||||
//! and quotes, currency signs before an amount): the line-start and line-end prohibitions of
|
||||
//! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR.
|
||||
//!
|
||||
//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose
|
||||
//! words need a dictionary to find.
|
||||
|
||||
/// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID).
|
||||
fn ideographic(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{2e80}'..='\u{2fdf}' // CJK radicals, Kangxi radicals
|
||||
| '\u{3000}'..='\u{303f}' // CJK symbols and punctuation
|
||||
| '\u{3040}'..='\u{30ff}' // hiragana, katakana
|
||||
| '\u{3190}'..='\u{31ff}' // kanbun, CJK strokes, katakana phonetic extensions
|
||||
| '\u{3400}'..='\u{4dbf}' // CJK extension A
|
||||
| '\u{4e00}'..='\u{9fff}' // CJK unified ideographs
|
||||
| '\u{f900}'..='\u{faff}' // CJK compatibility ideographs
|
||||
| '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms
|
||||
| '\u{ff00}'..='\u{ffef}' // half-width and full-width forms
|
||||
| '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes
|
||||
)
|
||||
}
|
||||
|
||||
/// Characters that must not begin a row: closing brackets and quotes, and the punctuation, small
|
||||
/// kana and marks that belong to the character before them (UAX #14 CL, CP, EX, IS, NS). The
|
||||
/// ideographic space breaks after itself, not before (BA): it is Japanese spacing and indentation.
|
||||
fn no_start(c: char) -> bool {
|
||||
matches!(c,
|
||||
')' | ']' | '}' | '»' | '’' | '”' | '!' | '?' | ',' | '.' | ':' | ';' | '%' | '‰' | '°' | '℃'
|
||||
| '‐' | '–' | '〜' | '‼' | '⁇' | '⁈' | '⁉' | '・' | '\u{3000}'
|
||||
| '、' | '。' | '〃' | '〆' | '々' | '〻' | '〕' | '〉' | '》' | '」' | '』' | '】' | '〙' | '〗' | '〟' | '〞'
|
||||
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
|
||||
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ'
|
||||
| '゛' | '゜' | 'ゝ' | 'ゞ' | '゠' | 'ー' | 'ヽ' | 'ヾ'
|
||||
| '\u{31f0}'..='\u{31ff}' // small katakana for Ainu
|
||||
| '!' | '%' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '⦆' | '~'
|
||||
| '。' | '」' | '、' | '・' | 'ー' | '゙' | '゚'
|
||||
| '\u{ff67}'..='\u{ff6f}' // small half-width katakana
|
||||
)
|
||||
}
|
||||
|
||||
/// Characters that must not end a row: opening brackets and quotes, and currency signs written
|
||||
/// before an amount (UAX #14 OP, PR).
|
||||
fn no_end(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'(' | '['
|
||||
| '{'
|
||||
| '«'
|
||||
| '‘'
|
||||
| '“'
|
||||
| '$'
|
||||
| '£'
|
||||
| '¥'
|
||||
| '€'
|
||||
| '〈'
|
||||
| '《'
|
||||
| '「'
|
||||
| '『'
|
||||
| '【'
|
||||
| '〔'
|
||||
| '〖'
|
||||
| '〘'
|
||||
| '〝'
|
||||
| '('
|
||||
| '['
|
||||
| '{'
|
||||
| '⦅'
|
||||
| '「'
|
||||
| '$'
|
||||
| '£'
|
||||
| '¥'
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a row may end between `before` and `after`, neither of them a space.
|
||||
pub(crate) fn between(before: char, after: char) -> bool {
|
||||
(ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before)
|
||||
}
|
||||
|
||||
/// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing.
|
||||
pub(crate) fn pieces(word: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut prev: Option<char> = None;
|
||||
for (at, c) in word.char_indices() {
|
||||
if let Some(before) = prev
|
||||
&& between(before, c)
|
||||
{
|
||||
out.extend(word.get(start..at));
|
||||
start = at;
|
||||
}
|
||||
prev = Some(c);
|
||||
}
|
||||
out.extend(word.get(start..).filter(|rest| !rest.is_empty()));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ideographs_and_kana_break_between_characters_except_around_kinsoku_marks() {
|
||||
assert_eq!(pieces("日本語の文章"), ["日", "本", "語", "の", "文", "章"]);
|
||||
// Closing punctuation and small kana stay with the character before them; an opening
|
||||
// bracket stays with the one after it.
|
||||
assert_eq!(pieces("ちょっと、「はい」。"), ["ちょっ", "と、", "「は", "い」。"]);
|
||||
assert_eq!(pieces("コーヒー"), ["コー", "ヒー"]);
|
||||
assert_eq!(pieces("(例)です"), ["(例)", "で", "す"]);
|
||||
// Latin text inside a Japanese sentence breaks before and after it, never inside it.
|
||||
assert_eq!(pieces("PDFを編集"), ["PDF", "を", "編", "集"]);
|
||||
assert_eq!(pieces("価格は¥100です"), ["価", "格", "は", "¥100", "で", "す"]);
|
||||
// Words of space-separated writing are never cut.
|
||||
assert_eq!(pieces("wrapping"), ["wrapping"]);
|
||||
assert_eq!(pieces("한국어"), ["한국어"]);
|
||||
assert_eq!(pieces(""), Vec::<&str>::new());
|
||||
assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture(parts: &[&[u8]]) -> Document {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let streams = parts.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))))).collect();
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||||
page.set(b"Contents".to_vec(), Object::Array(streams));
|
||||
let page = doc.add(page);
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
fn placed(parts: &[&[u8]]) -> (Document, Vec<Object>) {
|
||||
let mut doc = fixture(parts);
|
||||
stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()).unwrap();
|
||||
let page = page_list(&doc).remove(0);
|
||||
let list = contents(&mut doc, &page).unwrap();
|
||||
(doc, list)
|
||||
}
|
||||
|
||||
fn close(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let (doc, list) = placed(parts);
|
||||
let object = &list[list.len() - 2];
|
||||
assert_eq!(stream_tag(&doc, object).as_deref(), Some("Wrap"));
|
||||
let bytes = stream_bytes(&doc, object).unwrap();
|
||||
assert_eq!(wrapper_role(&doc, object), Some(false));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_only_marked_operators_not_strings_names_comments_or_image_samples() {
|
||||
for tail in
|
||||
[b"/Span << /Text (BMC BDC EMC) >> DP".as_slice(), b"/BMC /EMC DP", b"% BMC BDC EMC", b"BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n"]
|
||||
{
|
||||
let mut input = b"/OC /L1 BDC\n".to_vec();
|
||||
input.extend_from_slice(tail);
|
||||
assert_eq!(close(&[&input]), b"\nEMC\nQ %PdfCraft\n", "{tail:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_suffix_stays_outside_marked_content_across_balanced_graphics() {
|
||||
let (doc, list) = placed(&[b"q /OC /L1 BDC Q", b"50 Tz"]);
|
||||
assert_eq!(stream_bytes(&doc, &list[0]).unwrap(), WRAP_OPEN);
|
||||
assert_eq!(stream_bytes(&doc, &list[1]).unwrap(), b"q /OC /L1 BDC Q");
|
||||
assert_eq!(stream_bytes(&doc, &list[list.len() - 2]).unwrap(), b"\nEMC\nQ %PdfCraft\n");
|
||||
assert_eq!(close(&[b"q /OC /L1 BDC Q"]), b"\nEMC\nQ %PdfCraft\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marked_depth_survives_unknown_graphics_and_tolerant_operands() {
|
||||
for (input, expected) in [
|
||||
(b"/OC /L1 BDC q".as_slice(), b"\nEMC\nQ\nQ %PdfCraft\n".as_slice()),
|
||||
(b"Q /OC /L1 BDC", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC BT", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC 0 0 m", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC 123 BMC /Artifact BDC", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"EMC 123 /OC /L1 BDC 123 /Artifact BMC", b"\nEMC\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC 123 EMC", b"\nQ %PdfCraft\n"),
|
||||
(b"/Span 123 BDC", b"\nEMC\nQ %PdfCraft\n"),
|
||||
] {
|
||||
assert_eq!(close(&[input]), expected, "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_cross_stream_begins_without_closing_balanced_sequences_twice() {
|
||||
assert_eq!(close(&[b"/OC", b"/L1 BDC"]), b"\nEMC\nQ %PdfCraft\n");
|
||||
assert_eq!(close(&[b"/OC /L1 BDC", b"EMC"]), WRAP_CLOSE);
|
||||
assert_eq!(close(&[b"/OC /L1 BDC % EMC", b"EMC\n"]), b"\nEMC\nQ %PdfCraft\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() {
|
||||
for bytes in [WRAP_CLOSE, OLD_WRAP_CLOSE, b"\nQ\nQ %PdfCraft\n", b"\nEMC\nQ %PdfCraft\n", b"\nEMC\nEMC\nQ\nQ %PdfCraft\n"] {
|
||||
assert!(is_wrap_close(bytes), "{bytes:?}");
|
||||
}
|
||||
for bytes in [
|
||||
b"\nEMCQ %PdfCraft\n".as_slice(),
|
||||
b"\nEMC\nQQ %PdfCraft\n",
|
||||
b"\nQ\nEMC\nQ %PdfCraft\n",
|
||||
b"EMC\nQ %PdfCraft\n",
|
||||
b"\nEMC 1 w\nQ %PdfCraft\n",
|
||||
b"\nEMC\nQ %PdfCraft\nq Q",
|
||||
] {
|
||||
assert!(!is_wrap_close(bytes), "{bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
|
||||
let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
|
||||
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
|
||||
expected.extend_from_slice(WRAP_CLOSE);
|
||||
assert_eq!(close(&[&maximum]), expected);
|
||||
let mut combined = maximum.clone();
|
||||
combined.extend_from_slice(&b"q\n".repeat(ISOLATION_MAX_DEPTH));
|
||||
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
|
||||
expected.extend_from_slice(&b"\nQ".repeat(ISOLATION_MAX_DEPTH));
|
||||
expected.extend_from_slice(WRAP_CLOSE);
|
||||
assert_eq!(close(&[&combined]), expected);
|
||||
assert!(expected.len() < 64 * 1024, "maximum generated close remains recognizable for removal");
|
||||
let mut doc = fixture(&[&combined]);
|
||||
let page = page_list(&doc).remove(0);
|
||||
let original = contents(&mut doc, &page).unwrap();
|
||||
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap();
|
||||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
|
||||
assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers");
|
||||
for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] {
|
||||
let mut over = maximum.clone();
|
||||
over.extend_from_slice(suffix);
|
||||
assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC");
|
||||
}
|
||||
}
|
||||
+1533
-1
File diff suppressed because it is too large.
Load diff
+553
-96
File diff suppressed because it is too large.
Load diff
+153
-31
@@ -17,14 +17,16 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
|
||||
}
|
||||
|
||||
fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
|
||||
row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union)
|
||||
}
|
||||
|
||||
/// [`row_rect`] in its text's own frame ([`TextLine::frame_rect`]), so rows of turned text
|
||||
/// compare along and across their baselines.
|
||||
fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
|
||||
row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union)
|
||||
}
|
||||
|
||||
pub(super) fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
|
||||
group_layout(lines, true)
|
||||
}
|
||||
@@ -62,14 +64,14 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
let mut rows: Vec<Vec<usize>> = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| {
|
||||
let gap = line.rect[0] - prev.rect[2];
|
||||
let gap = line.frame_rect()[0] - prev.frame_rect()[2];
|
||||
// OCR words (invisible text over a scan) join only across a word space: a wider gap
|
||||
// is a table cell or a tab stop, which a rewrite would close up.
|
||||
let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6));
|
||||
scanned
|
||||
&& (mixed || same_style(prev, line))
|
||||
&& same_plane(prev, line)
|
||||
&& (prev.origin.baseline - line.origin.baseline).abs() < prev.size.min(line.size) * 0.2
|
||||
&& (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2
|
||||
&& gap >= -prev.size.min(line.size) * 0.2
|
||||
&& gap < prev.size.max(line.size) * 1.5
|
||||
// Separate same-style text objects that merely touch or overlap, with no space
|
||||
@@ -90,14 +92,15 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
let mut blocks: Vec<TextBlock> = Vec::new();
|
||||
for row in rows {
|
||||
let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue };
|
||||
let Some(rect) = row_rect(&row, lines) else { continue };
|
||||
let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue };
|
||||
let joins = blocks.last().is_some_and(|block| {
|
||||
let Some(prev_row) = block.rows.last() else { return false };
|
||||
let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false };
|
||||
let Some(pr) = row_rect(prev_row, lines) else { return false };
|
||||
let gap = prev.origin.baseline - first.origin.baseline;
|
||||
let Some(pr) = frame_row_rect(prev_row, lines) else { return false };
|
||||
let gap = prev.frame_origin().1 - first.frame_origin().1;
|
||||
let tol = prev.size.max(first.size).max(1.0);
|
||||
let aligned = (pr[0] - rect[0]).abs() < tol || ((pr[0] + pr[2] - rect[0] - rect[2]) / 2.0).abs() < tol || (pr[2] - rect[2]).abs() < tol;
|
||||
let aligned =
|
||||
(pr[0] - frame[0]).abs() < tol || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol;
|
||||
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
|
||||
(!mixed || !(invisible(prev) || invisible(first)))
|
||||
&& (mixed || same_style(prev, first))
|
||||
@@ -135,7 +138,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
|
||||
&& !row_text.ends_with(char::is_whitespace)
|
||||
&& !line.text.starts_with(char::is_whitespace)
|
||||
&& line.rect[0] - prev.rect[2] > line.size * 0.12
|
||||
&& line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12
|
||||
{
|
||||
row_text.push(' ');
|
||||
block.editor_text.push(' ');
|
||||
@@ -192,6 +195,46 @@ fn shape_source(
|
||||
Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into()))
|
||||
}
|
||||
|
||||
/// For each output row (a range of the draft's characters), whether it is exactly the source row
|
||||
/// at the same position: the same characters, none of them inserted or changed by the edit.
|
||||
fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>]) -> Vec<bool> {
|
||||
let old: Vec<char> = block.editor_text.chars().collect();
|
||||
let new: Vec<char> = draft.chars().collect();
|
||||
let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
|
||||
let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count();
|
||||
// The source character a draft character is, if the edit left it alone.
|
||||
let origin = |i: usize| {
|
||||
if i < prefix {
|
||||
Some(i)
|
||||
} else if i >= new.len().saturating_sub(suffix) {
|
||||
(old.len() + i).checked_sub(new.len())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut source_rows = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, ch) in old.iter().enumerate() {
|
||||
if *ch == '\n' {
|
||||
source_rows.push(start..i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
source_rows.push(start..old.len());
|
||||
// A line break inside a source run would pair editor rows with the wrong visual rows.
|
||||
if source_rows.len() != block.rows.len() {
|
||||
return vec![false; rows.len()];
|
||||
}
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(r, range)| {
|
||||
source_rows.get(r).is_some_and(|source| {
|
||||
!range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
|
||||
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
|
||||
pub(super) fn rewrite_styled_block(
|
||||
@@ -227,6 +270,7 @@ pub(super) fn rewrite_styled_block(
|
||||
{
|
||||
return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into()));
|
||||
}
|
||||
let source_count = lines.len();
|
||||
let mut styled_lines = lines.to_vec();
|
||||
let mut styled_spans = Vec::new();
|
||||
let mut underlines = HashMap::new();
|
||||
@@ -331,8 +375,9 @@ pub(super) fn rewrite_styled_block(
|
||||
if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) {
|
||||
return Err(EditError::Invalid("the paragraph's position and width must be finite".into()));
|
||||
}
|
||||
// A rewritten OCR line grows to the right rather than wrapping onto the scanned line below.
|
||||
let natural = if block.invisible {
|
||||
// A visual row grows even when it contains several source/font runs. Multi-row
|
||||
// paragraphs retain their container width; an explicit width always wins.
|
||||
let natural = if block.invisible || block.rows.len() == 1 {
|
||||
(p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0])
|
||||
} else {
|
||||
block.rect[2] - block.rect[0]
|
||||
@@ -343,6 +388,7 @@ pub(super) fn rewrite_styled_block(
|
||||
let mut substituted = None;
|
||||
let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new();
|
||||
let mut full_faces: HashMap<String, String> = HashMap::new();
|
||||
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = HashMap::new();
|
||||
for (range, index) in styled_spans {
|
||||
let source = lines.get(index).unwrap_or(first);
|
||||
for ch in draft.get(range).unwrap_or_default().chars() {
|
||||
@@ -378,6 +424,28 @@ pub(super) fn rewrite_styled_block(
|
||||
full_faces.insert(key, name.clone());
|
||||
name
|
||||
}
|
||||
} else if let Some(bytes) = programs
|
||||
.entry(source.font.clone())
|
||||
.or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new))
|
||||
.clone()
|
||||
.filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s))
|
||||
{
|
||||
// The document's own complete program of this font has the glyph: draw it with
|
||||
// that face rather than another family's fallback.
|
||||
let key = format!("source {}", source.font);
|
||||
if let Some(name) = full_faces.get(&key) {
|
||||
name.clone()
|
||||
} else {
|
||||
let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family);
|
||||
let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?;
|
||||
let font = fonts
|
||||
.get(name.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?;
|
||||
metrics.insert(name.clone(), Metrics::from_dict(doc, &font));
|
||||
full_faces.insert(key, name.clone());
|
||||
name
|
||||
}
|
||||
} else {
|
||||
let family = source_family(&source.base_font);
|
||||
if needs_type3(&s) {
|
||||
@@ -421,6 +489,20 @@ pub(super) fn rewrite_styled_block(
|
||||
};
|
||||
// A source run may carry TJ kerning/justification that is absent from its text state.
|
||||
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
|
||||
// Each font's space width, worked out once: when a font can't show a space, finding one
|
||||
// reads every font on the page, and doing that for every character of a paragraph made each
|
||||
// keystroke in a long paragraph take half a second (#2616).
|
||||
// Only for the fonts the paragraph uses, when first asked: working out every page font's width
|
||||
// up front would scan the page once per font for a short edit on a page of many fonts.
|
||||
let spaces: std::cell::RefCell<HashMap<String, f64>> = std::cell::RefCell::new(HashMap::new());
|
||||
let space_of = |name: &str, m: &Metrics| {
|
||||
if let Some(w) = spaces.borrow().get(name) {
|
||||
return *w;
|
||||
}
|
||||
let w = source_space(doc, &fonts, m);
|
||||
spaces.borrow_mut().insert(name.to_owned(), w);
|
||||
w
|
||||
};
|
||||
let mut calibration = HashMap::new();
|
||||
for (index, source) in lines.iter().enumerate() {
|
||||
if let Some(m) = metrics.get(&source.font) {
|
||||
@@ -428,7 +510,7 @@ pub(super) fn rewrite_styled_block(
|
||||
m,
|
||||
&source.text,
|
||||
source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s),
|
||||
source_space(doc, &fonts, m),
|
||||
space_of(&source.font, m),
|
||||
source.origin.state.char_spacing,
|
||||
source.origin.state.word_spacing,
|
||||
) * source.origin.state.scale
|
||||
@@ -445,7 +527,7 @@ pub(super) fn rewrite_styled_block(
|
||||
m,
|
||||
&item.0.to_string(),
|
||||
size_of(source, item.1),
|
||||
source_space(doc, &fonts, m),
|
||||
space_of(&item.2, m),
|
||||
overrides
|
||||
.get(&item.1)
|
||||
.and_then(|r| r.char_spacing)
|
||||
@@ -488,6 +570,9 @@ pub(super) fn rewrite_styled_block(
|
||||
Ok(width)
|
||||
};
|
||||
let mut rows: Vec<Vec<(char, usize, String)>> = Vec::new();
|
||||
// Each row's characters, as indexes into the draft's characters (`chars` holds all of them).
|
||||
let mut row_ranges = Vec::new();
|
||||
let mut row_start = 0;
|
||||
let mut row = Vec::new();
|
||||
let mut row_width = 0.0;
|
||||
// Wrap at word boundaries, retaining explicit source row breaks and whitespace.
|
||||
@@ -495,14 +580,20 @@ pub(super) fn rewrite_styled_block(
|
||||
while at < chars.len() {
|
||||
let Some(item) = chars.get(at) else { break };
|
||||
if item.0 == '\n' {
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
at += 1;
|
||||
row_start = at;
|
||||
continue;
|
||||
}
|
||||
let mut end = at + 1;
|
||||
if !item.0.is_whitespace() {
|
||||
while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) {
|
||||
// A word ends at a space, or where Chinese or Japanese text may break between characters.
|
||||
while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end))
|
||||
&& !c.0.is_whitespace()
|
||||
&& !crate::linebreak::between(before.0, c.0)
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
@@ -512,17 +603,48 @@ pub(super) fn rewrite_styled_block(
|
||||
while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) {
|
||||
row.pop();
|
||||
}
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
row_start = at;
|
||||
}
|
||||
row.extend_from_slice(word);
|
||||
row_width += w;
|
||||
at = end;
|
||||
}
|
||||
if !row.is_empty() {
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(row);
|
||||
}
|
||||
for row in &rows {
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
|
||||
// glyph widths stay exactly as they were instead of being rebuilt from the text state.
|
||||
let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
let mut kept = if plain {
|
||||
untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect()
|
||||
} else {
|
||||
vec![false; rows.len()]
|
||||
};
|
||||
let kept_lines: std::collections::HashSet<usize> =
|
||||
kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect();
|
||||
let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect();
|
||||
// The rewrite goes where the last rewritten row was drawn, after any backgrounds painted
|
||||
// between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on.
|
||||
let lead =
|
||||
rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))));
|
||||
let (members, lead) = match lead {
|
||||
Some(lead) if !kept_lines.is_empty() => (rewritten, lead),
|
||||
_ => {
|
||||
kept.iter_mut().for_each(|k| *k = false);
|
||||
let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
|
||||
let last = all.last().copied().unwrap_or(first);
|
||||
(all, last)
|
||||
}
|
||||
};
|
||||
let o = &lead.origin;
|
||||
for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) {
|
||||
let text: String = row.iter().map(|v| v.0).collect();
|
||||
if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) {
|
||||
return Err(EditError::Invalid(
|
||||
@@ -534,7 +656,7 @@ pub(super) fn rewrite_styled_block(
|
||||
let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::<Result<_, _>>()?;
|
||||
let mut shaped_runs = HashMap::new();
|
||||
let mut segment_advances = HashMap::new();
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) {
|
||||
let mut at = 0;
|
||||
while let Some(item) = row.get(at) {
|
||||
let mut end = at + 1;
|
||||
@@ -557,8 +679,8 @@ pub(super) fn rewrite_styled_block(
|
||||
}
|
||||
let n = pdfcraft_content::num;
|
||||
let mut block_ops = vec![Op::new("q", vec![])];
|
||||
block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?);
|
||||
let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0])
|
||||
block_ops.extend(reveal_ops(block, lines, style, o.ctm)?);
|
||||
let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0])
|
||||
.invert()
|
||||
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
|
||||
let (tx, ty) = back.apply(dx, dy);
|
||||
@@ -589,6 +711,11 @@ pub(super) fn rewrite_styled_block(
|
||||
baseline = anchor.origin.baseline;
|
||||
}
|
||||
let anchor = anchor.unwrap_or(first);
|
||||
if kept.get(row_index).copied().unwrap_or(false) {
|
||||
// Kept rows only occur without spacing or size changes, so this is the step below.
|
||||
baseline -= source_leading;
|
||||
continue;
|
||||
}
|
||||
let w = measured.get(row_index).copied().unwrap_or(0.0);
|
||||
let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
|
||||
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
|
||||
@@ -625,7 +752,7 @@ pub(super) fn rewrite_styled_block(
|
||||
if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) {
|
||||
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])];
|
||||
}
|
||||
let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?;
|
||||
let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?;
|
||||
let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0;
|
||||
let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline);
|
||||
tm[4] += ax;
|
||||
@@ -665,7 +792,7 @@ pub(super) fn rewrite_styled_block(
|
||||
m,
|
||||
&string,
|
||||
size_of(source, item.1),
|
||||
source_space(doc, &fonts, m),
|
||||
spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)),
|
||||
state.char_spacing,
|
||||
state.word_spacing,
|
||||
)?);
|
||||
@@ -674,10 +801,10 @@ pub(super) fn rewrite_styled_block(
|
||||
if underlines.get(&item.1).copied().or(style.underline) == Some(true) {
|
||||
let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
|
||||
let underline_size = size_of(source, item.1) * source.origin.k;
|
||||
let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
|
||||
let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
|
||||
let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
|
||||
let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
|
||||
block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)]));
|
||||
block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))]));
|
||||
block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))]));
|
||||
block_ops.push(Op::new("m", vec![n(x0), n(y0)]));
|
||||
block_ops.push(Op::new("l", vec![n(x1), n(y1)]));
|
||||
block_ops.push(Op::new("S", vec![]));
|
||||
@@ -692,14 +819,9 @@ pub(super) fn rewrite_styled_block(
|
||||
};
|
||||
}
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
|
||||
let mut drop = coincident_ops(lines, &members);
|
||||
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
|
||||
let o = &first.origin;
|
||||
let start = first.ops.first().copied().unwrap_or(o.bt_op);
|
||||
let start = lead.ops.first().copied().unwrap_or(o.bt_op);
|
||||
let split = ops
|
||||
.get(o.bt_op..start)
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-docpath = { workspace = true }
|
||||
pdfcraft-measure = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
|
||||
pdfcraft-render = { workspace = true }
|
||||
@@ -36,11 +37,15 @@ pdfcraft-model = { workspace = true }
|
||||
pdfcraft-geom = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
png = "0.18"
|
||||
# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the
|
||||
# resolution or the compression of an exported page.
|
||||
tiff = "0.11"
|
||||
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
|
||||
# the host of a document's link the way the browser reads it (links::display_host).
|
||||
url = "2.5"
|
||||
idna = "1.1"
|
||||
icu_properties = "2.3"
|
||||
unicode-segmentation = "1.13"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Already in the graph through lopdf; used only for the local UTC offset ("today").
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
The façade every frontend talks to. It holds the open documents, their edit history and the tool
|
||||
catalogue; frontends never reach past it into the parsing, rendering or editing crates.
|
||||
|
||||
`word_count::count` provides cancellable, page-at-a-time Unicode word and character statistics
|
||||
with optional semantic exclusions; see [word-count behavior and limits](../../docs/word-count.md).
|
||||
|
||||
- **Layer:** L6. It depends on most of L1–L4 and on nothing above itself. **No UI toolkit** —
|
||||
`egui`, `winit`, `eframe` and `rfd` are forbidden below L7, and `xtask layers` enforces it.
|
||||
- The crate denies `unwrap`/`expect`/`panic!`/`todo!`. Entry points that run untrusted-document
|
||||
|
||||
@@ -24,6 +24,11 @@ pub struct ToolItem {
|
||||
pub icon: &'static str,
|
||||
pub command: &'static str,
|
||||
pub availability: Availability,
|
||||
/// Further commands of the same tool, shown as a drop-down beside the entry: Acrobat's
|
||||
/// Watermark control offers Add, Update and Remove. The first is `command` itself. A UI
|
||||
/// enables each one by its own command's needs, so Update and Remove stay greyed out until
|
||||
/// the document has that kind of mark. Empty for a plain entry.
|
||||
pub actions: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -45,7 +50,16 @@ pub struct ToolGroup {
|
||||
}
|
||||
|
||||
const fn item(label: &'static str, icon: &'static str, command: &'static str, availability: Availability) -> ToolItem {
|
||||
ToolItem { label, icon, command, availability }
|
||||
ToolItem { label, icon, command, availability, actions: &[] }
|
||||
}
|
||||
|
||||
/// An entry that opens a menu of `actions` (the first one is its `command`).
|
||||
const fn menu_item(label: &'static str, icon: &'static str, actions: &'static [&'static str]) -> ToolItem {
|
||||
let command = match actions {
|
||||
[first, ..] => *first,
|
||||
[] => "",
|
||||
};
|
||||
ToolItem { label, icon, command, availability: Ready, actions }
|
||||
}
|
||||
|
||||
use Availability::{Planned, Provider, Ready};
|
||||
@@ -115,6 +129,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
ToolSection {
|
||||
title: "Modify page",
|
||||
items: &[
|
||||
item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready),
|
||||
item("Rotate pages", "rotate-cw", "page.rotate", Ready),
|
||||
item("Insert pages", "file-plus-2", "page.insert", Ready),
|
||||
item("Delete pages", "trash-2", "page.delete", Ready),
|
||||
@@ -125,14 +140,13 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
ToolSection {
|
||||
title: "Add content",
|
||||
items: &[
|
||||
item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready),
|
||||
item("Text", "type", "edit.text", Ready),
|
||||
item("Image", "image-plus", "edit.image", Ready),
|
||||
item("Header and footer", "heading", "edit.header_footer", Ready),
|
||||
item("Watermark", "stamp", "edit.watermark", Ready),
|
||||
menu_item("Header and footer", "heading", &["edit.header_footer", "edit.header_footer.update", "edit.header_footer.remove"]),
|
||||
menu_item("Watermark", "stamp", &["edit.watermark", "edit.watermark.update", "edit.watermark.remove"]),
|
||||
item("Link", "link-2", "edit.link", Ready),
|
||||
item("Bates numbering", "hash", "edit.bates", Ready),
|
||||
item("Background", "palette", "edit.background", Ready),
|
||||
menu_item("Background", "palette", &["edit.background", "edit.background.update", "edit.background.remove"]),
|
||||
item("Attach file", "paperclip", "edit.attach", Planned("M12")),
|
||||
],
|
||||
},
|
||||
@@ -192,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
|
||||
item("Crop pages", "crop", "page.crop", Ready),
|
||||
item("Duplicate pages", "copy-plus", "page.duplicate", Ready),
|
||||
item("Reverse pages", "arrow-up-down", "page.reverse", Ready),
|
||||
item("Page labels", "tag", "page.number", Ready),
|
||||
item("Page transitions", "presentation", "page.transitions", Planned("M4")),
|
||||
],
|
||||
@@ -458,8 +473,10 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
title: "Accessibility",
|
||||
items: &[
|
||||
item("Automatically tag PDF", "tag", "a11y.autotag", Ready),
|
||||
item("Tag form fields", "tag", "a11y.autotag_fields", Ready),
|
||||
item("Change reading options", "book-open", "a11y.reading_options", Ready),
|
||||
item("Check for accessibility", "accessibility", "a11y.check", Ready),
|
||||
item("Find untagged content", "search", "a11y.find_untagged", Ready),
|
||||
item("Open accessibility report", "file-text", "a11y.report", Ready),
|
||||
item("Add alternate text", "image", "a11y.alt_text", Ready),
|
||||
item("Tags panel", "tag", "a11y.tags", Ready),
|
||||
@@ -555,4 +572,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_entries_list_registered_commands_starting_with_their_own() {
|
||||
let mut menus = 0;
|
||||
for g in TOOL_GROUPS {
|
||||
for s in g.sections {
|
||||
for i in s.items {
|
||||
if i.actions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
menus += 1;
|
||||
assert_eq!(i.actions[0], i.command, "{}: the first action is the entry's command", i.label);
|
||||
for id in i.actions {
|
||||
assert!(crate::commands::command(id).is_some(), "{id} is not a registered command");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Watermark, Header and footer, Background: Add / Update / Remove each (Acrobat's menu).
|
||||
assert!(menus >= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_update_and_remove_command_is_reachable_from_a_tool_entry() {
|
||||
let reachable: HashSet<&str> =
|
||||
TOOL_GROUPS.iter().flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter())).flat_map(|i| i.actions.iter().copied()).collect();
|
||||
for id in crate::commands::COMMANDS
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.filter(|id| id.starts_with("edit.") && (id.ends_with(".update") || id.ends_with(".remove")))
|
||||
{
|
||||
assert!(reachable.contains(id), "{id} is in no tool menu");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"),
|
||||
c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"),
|
||||
c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"),
|
||||
c("file.word_count", "Word count…", FILE, None, Copying, "hash"),
|
||||
ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"),
|
||||
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
|
||||
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
|
||||
@@ -212,6 +213,8 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
// Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option-
|
||||
// drag with the Select tool does the same; this is the way in where the desktop takes
|
||||
// Alt-drag for moving windows (several Linux window managers).
|
||||
// Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership.
|
||||
c("edit.copy", "Copy", EDIT, None, Copying, "copy"),
|
||||
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
|
||||
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
|
||||
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
|
||||
@@ -221,6 +224,9 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
// also run from Combine files, which has no document open.
|
||||
c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"),
|
||||
c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"),
|
||||
// View-local history also appears in the Page navigation section; don't duplicate its menu.
|
||||
c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"),
|
||||
c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"),
|
||||
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
|
||||
c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"),
|
||||
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
|
||||
@@ -326,10 +332,12 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
|
||||
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
|
||||
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
|
||||
c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"),
|
||||
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
|
||||
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
|
||||
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
|
||||
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
|
||||
c("a11y.autotag_fields", "Tag form fields", None, None, Modification, "tag"),
|
||||
c("a11y.tags", "Tags panel", None, None, Document, "tag"),
|
||||
c("export.all_images", "Export all images…", FILE, None, Copying, "image"),
|
||||
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
|
||||
@@ -354,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
|
||||
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
|
||||
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
|
||||
c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
|
||||
c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"),
|
||||
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
|
||||
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
|
||||
@@ -390,7 +399,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
|
||||
Annotate => doc.is_some_and(|d| d.allows_annotation()),
|
||||
Copying => doc.is_some_and(|d| d.allows_copying()),
|
||||
FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()),
|
||||
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)),
|
||||
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks().contains(&k)),
|
||||
HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()),
|
||||
HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()),
|
||||
HasRedactions => doc.is_some_and(|d| d.allows_modification() && d.redaction_marks() > 0),
|
||||
|
||||
@@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
|
||||
|
||||
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
|
||||
/// the interface languages, in the same order and with the same names.
|
||||
pub static DATE_LANGUAGES: [DateLanguage; 22] = [
|
||||
pub static DATE_LANGUAGES: [DateLanguage; 23] = [
|
||||
DateLanguage {
|
||||
code: "en",
|
||||
name: "English",
|
||||
@@ -360,6 +360,44 @@ pub static DATE_LANGUAGES: [DateLanguage; 22] = [
|
||||
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
|
||||
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
|
||||
},
|
||||
// Vietnamese: numeric month forms (`tháng 1` with a day, `Tháng 1` standing alone).
|
||||
// Fill & Sign still refuses any format whose final text fails the existing WinAnsi check
|
||||
// (for example weekday names); `dd.mm.yyyy` remains writable.
|
||||
DateLanguage {
|
||||
code: "vi",
|
||||
name: "Tiếng Việt",
|
||||
months: [
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
],
|
||||
months_with_day: Some([
|
||||
"tháng 1",
|
||||
"tháng 2",
|
||||
"tháng 3",
|
||||
"tháng 4",
|
||||
"tháng 5",
|
||||
"tháng 6",
|
||||
"tháng 7",
|
||||
"tháng 8",
|
||||
"tháng 9",
|
||||
"tháng 10",
|
||||
"tháng 11",
|
||||
"tháng 12",
|
||||
]),
|
||||
months_short: ["Thg 1", "Thg 2", "Thg 3", "Thg 4", "Thg 5", "Thg 6", "Thg 7", "Thg 8", "Thg 9", "Thg 10", "Thg 11", "Thg 12"],
|
||||
days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"],
|
||||
days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"],
|
||||
},
|
||||
];
|
||||
|
||||
/// The date language for `code` (any case), or `None` when there's none.
|
||||
@@ -631,6 +669,26 @@ mod tests {
|
||||
assert_eq!(s.date_format(), "dd.mm.yyyy", "a rejected pattern keeps the previous one");
|
||||
}
|
||||
|
||||
/// 21:30 on 14 Oct 2026 in São Paulo (UTC-3) is 00:30Z on the 15th. The date Fill & Sign
|
||||
/// stamps is the wall-clock day (14th); the time the file records (`/M`, `/ModDate`) is UTC.
|
||||
/// The test shortcut (`with_clock`) takes the injected time as local and says the 15th, which
|
||||
/// is why it cannot serve a host that knows both the UTC time and the zone.
|
||||
#[test]
|
||||
fn a_wall_clock_gives_the_local_day_and_keeps_utc_for_the_file() {
|
||||
let sao_paulo = Session::new().with_wall_clock(|| 1_792_024_200, || -3 * 3600);
|
||||
assert_eq!(sao_paulo.today(), (2026, 10, 14));
|
||||
assert_eq!(sao_paulo.today_text(None, None).as_deref(), Ok("10/14/2026"));
|
||||
assert_eq!(sao_paulo.signing_date(), "D:20261014213000-03'00'");
|
||||
assert_eq!(sao_paulo.stamp_by_line("Ada"), "By Ada at 9:30 pm, Oct 14, 2026");
|
||||
assert_eq!(sao_paulo.now_secs(), 1_792_024_200, "the file's instant stays in UTC");
|
||||
|
||||
let utc = Session::new().with_wall_clock(|| 1_792_024_200, || 0);
|
||||
assert_eq!(utc.today(), (2026, 10, 15));
|
||||
|
||||
let clock_only = Session::new().with_clock(|| 1_792_024_200);
|
||||
assert_eq!(clock_only.today(), (2026, 10, 15), "on its own, an injected clock counts as local time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_follow_the_date_language() {
|
||||
assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026");
|
||||
@@ -683,6 +741,25 @@ mod tests {
|
||||
assert_eq!(s.date_language(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vietnamese_month_and_weekday_names() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
s.set_date_language(Some("vi")).unwrap();
|
||||
assert_eq!(s.date_language(), Some("vi"));
|
||||
s.set_date_format("d mmmm yyyy").unwrap();
|
||||
assert_eq!(s.today_text(None, None).as_deref(), Ok("14 tháng 11 2023"));
|
||||
s.set_date_format("mmmm yyyy").unwrap();
|
||||
assert_eq!(s.today_text(None, None).as_deref(), Ok("Tháng 11 2023"));
|
||||
s.set_date_format("ddd, d mmm").unwrap();
|
||||
assert_eq!(s.today_text(None, None).as_deref(), Ok("T3, 14 Thg 11"));
|
||||
// Encoding follows the spelled text: `14 tháng 11 2023` is WinAnsi-representable;
|
||||
// a weekday name such as `Thứ Ba` is not, so Fill & Sign refuses that format.
|
||||
assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), Some("vi")).as_deref(), Ok("14 tháng 11 2023"));
|
||||
let e = s.today_text_for_pdf(Some("dddd d mmmm yyyy"), Some("vi")).unwrap_err();
|
||||
assert!(e.contains("can't be written into the PDF yet") && e.contains("dd.mm.yyyy"), "{e}");
|
||||
assert!(s.today_text_for_pdf(Some("dd.mm.yyyy"), Some("vi")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_the_pdf_cannot_hold_are_refused_not_damaged() {
|
||||
assert_eq!(unwritable("8. října 2026"), "ř");
|
||||
|
||||
+66
-18
@@ -27,7 +27,10 @@ pub struct ExportSource {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The current state, for exporting on another thread.
|
||||
/// What an export reads, for exporting on another thread. These are the displayed
|
||||
/// bytes, so they may carry missing comment appearances PdfCraft drew. Signature
|
||||
/// artwork stays original; viewer validation badges are not document content and
|
||||
/// do not enter image/text exports or Print as Image. Saving uses the working file.
|
||||
pub fn export_source(&self) -> ExportSource {
|
||||
ExportSource {
|
||||
bytes: self.display.clone(),
|
||||
@@ -46,8 +49,11 @@ pub fn extract_images(src: &ExportSource, pages: &[usize], min_side: u32) -> Res
|
||||
}
|
||||
|
||||
/// The file name for the `index`-th (1-based) exported image: `<stem>_Page_<n>_Image_<index>.<ext>`.
|
||||
pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize) -> String {
|
||||
format!("{stem}_Page_{}_Image_{index:04}.{}", image.page + 1, image.extension)
|
||||
/// The page number has leading zeros for as many digits as the document's `page_count` has, so the
|
||||
/// files sort in page order (#2208): page 7 of 120 is `Page_007`.
|
||||
pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize, page_count: usize) -> String {
|
||||
let width = page_count.max(1).to_string().len();
|
||||
format!("{stem}_Page_{:0width$}_Image_{index:04}.{}", image.page + 1, image.extension)
|
||||
}
|
||||
|
||||
impl Exporter {
|
||||
@@ -87,14 +93,14 @@ impl Exporter {
|
||||
encode_png(r.width, r.height, &r.rgba)
|
||||
}
|
||||
|
||||
/// Page `page` as an image file of `format`.
|
||||
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
|
||||
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
self.check(page)?;
|
||||
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
}
|
||||
encode_image(r.width, r.height, &r.rgba, format)
|
||||
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
|
||||
}
|
||||
|
||||
/// The reading-order text of a page.
|
||||
@@ -121,12 +127,14 @@ impl Exporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → an image file of `format`.
|
||||
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch,
|
||||
/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts
|
||||
/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink.
|
||||
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result<Vec<u8>, String> {
|
||||
match format {
|
||||
ImageFormat::Png => encode_png(width, height, premultiplied),
|
||||
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality),
|
||||
ImageFormat::Tiff => encode_tiff(width, height, premultiplied),
|
||||
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi),
|
||||
ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,22 +201,62 @@ fn over_white(premultiplied: &[u8]) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper).
|
||||
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result<Vec<u8>, String> {
|
||||
/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper).
|
||||
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result<Vec<u8>, String> {
|
||||
use image::ImageEncoder;
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity};
|
||||
let rgb = over_white(premultiplied);
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100))
|
||||
.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
|
||||
// The JFIF density is whole dots per inch.
|
||||
enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16));
|
||||
enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default).
|
||||
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use image::ImageEncoder;
|
||||
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly:
|
||||
/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed.
|
||||
/// No predictor: LZW alone is the compression TIFF readers support most widely after none.
|
||||
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result<Vec<u8>, String> {
|
||||
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
|
||||
use tiff::tags::{ResolutionUnit, Tag};
|
||||
let err = |e: tiff::TiffError| e.to_string();
|
||||
let rgb = over_white(premultiplied);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
|
||||
let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw);
|
||||
let mut image = tiff.new_image::<RGB8>(width, height).map_err(err)?;
|
||||
// In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number.
|
||||
let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 };
|
||||
let tags = image.encoder();
|
||||
tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?;
|
||||
tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?;
|
||||
tags.write_tag(Tag::YResolution, resolution).map_err(err)?;
|
||||
image.write_data(&rgb).map_err(err)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod image_name_tests {
|
||||
use super::image_file_name;
|
||||
|
||||
fn image(page: usize) -> pdfcraft_create::ExtractedImage {
|
||||
pdfcraft_create::ExtractedImage {
|
||||
page,
|
||||
object: pdfcraft_cos::ObjRef { num: 1, generation: 0 },
|
||||
width: 1,
|
||||
height: 1,
|
||||
extension: "png",
|
||||
data: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_is_padded_to_the_page_count() {
|
||||
assert_eq!(image_file_name("doc", &image(0), 1, 1), "doc_Page_1_Image_0001.png");
|
||||
assert_eq!(image_file_name("doc", &image(0), 1, 9), "doc_Page_1_Image_0001.png");
|
||||
assert_eq!(image_file_name("doc", &image(6), 2, 20), "doc_Page_07_Image_0002.png");
|
||||
assert_eq!(image_file_name("doc", &image(6), 3, 120), "doc_Page_007_Image_0003.png");
|
||||
assert_eq!(image_file_name("doc", &image(119), 4, 120), "doc_Page_120_Image_0004.png");
|
||||
assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png");
|
||||
}
|
||||
}
|
||||
+54
-23
@@ -6,7 +6,7 @@
|
||||
//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
|
||||
//! off, scripts don't run and AF calls keep working natively.
|
||||
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts};
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
|
||||
pub use pdfcraft_js::{Limits, Outcome, Request};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
@@ -250,6 +250,15 @@ fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::Pag
|
||||
/// A page's words in reading order, in user space ([x0, y0, x1, y1]); runs of underscores are
|
||||
/// words of their own.
|
||||
pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<(String, [f64; 4])> {
|
||||
words_with(text, |x, y| info.view_to_user(x, y))
|
||||
.into_iter()
|
||||
.map(|(text, r)| (text, [f64::from(r[0]), f64::from(r[1]), f64::from(r[2]), f64::from(r[3])]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered
|
||||
/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them).
|
||||
pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> {
|
||||
let mut words: Vec<(String, [f32; 4])> = Vec::new();
|
||||
let mut last_line = u32::MAX;
|
||||
// A space glyph ends the word before it.
|
||||
@@ -281,18 +290,23 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende
|
||||
words
|
||||
.into_iter()
|
||||
.map(|(text, r)| {
|
||||
let a = info.view_to_user(r[0], r[1]);
|
||||
let b = info.view_to_user(r[2], r[3]);
|
||||
let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64];
|
||||
(text, rect)
|
||||
let a = map(r[0], r[1]);
|
||||
let b = map(r[2], r[3]);
|
||||
(text, [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Prepare a form ▸ automatic field detection: the fields `pages` (0-based; empty = all)
|
||||
/// seem to ask for, named from their labels, as (page, candidate).
|
||||
/// seem to ask for, named from their labels, as (page, candidate). Scanned pages (a picture
|
||||
/// and no drawn shapes) are read from their pixels, see [`crate::scan_fields`].
|
||||
pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate)> {
|
||||
self.detect_fields_turned(id, pages).into_iter().map(|(page, c, _)| (page, c)).collect()
|
||||
}
|
||||
|
||||
/// [`Self::detect_fields`], with how far each field turns (`/MK /R`) to read upright.
|
||||
fn detect_fields_turned(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate, i64)> {
|
||||
let Some(doc) = self.get(id) else { return Vec::new() };
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
|
||||
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
|
||||
@@ -302,13 +316,19 @@ impl Session {
|
||||
let list: Vec<usize> = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() };
|
||||
let mut out = Vec::new();
|
||||
for page in list {
|
||||
let Some(info) = doc.info.pages.get(page) else { continue };
|
||||
let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
let words = res.text.map(|t| detection_words(&t, &doc.info.pages[page])).unwrap_or_default();
|
||||
let shapes = pdfcraft_forms::detect::page_shapes(cos, page);
|
||||
let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect();
|
||||
for c in pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken) {
|
||||
let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) {
|
||||
self.detect_on_scan(id, &mut r, page, res.text.as_deref(), &existing, &taken)
|
||||
} else {
|
||||
let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default();
|
||||
(pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0)
|
||||
};
|
||||
for c in found {
|
||||
taken.push(c.name.clone());
|
||||
out.push((page, c));
|
||||
out.push((page, c, turn));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -316,23 +336,34 @@ impl Session {
|
||||
|
||||
/// Detect fields and add them, as one undoable step. Returns the new fields' names.
|
||||
pub fn auto_detect_fields(&mut self, id: DocId, pages: &[usize]) -> Result<Vec<String>, EditError> {
|
||||
let found = self.detect_fields(id, pages);
|
||||
let found = self.detect_fields_turned(id, pages);
|
||||
if found.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let names = found.iter().map(|(_, c)| c.name.clone()).collect();
|
||||
let edits = found
|
||||
.into_iter()
|
||||
.map(|(page, c)| Edit::AddField {
|
||||
page,
|
||||
rect: c.rect,
|
||||
kind: match c.kind {
|
||||
pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: c.rect[3] - c.rect[1] > 30.0 },
|
||||
pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
|
||||
},
|
||||
name: Some(c.name),
|
||||
})
|
||||
.collect();
|
||||
let names = found.iter().map(|(_, c, _)| c.name.clone()).collect();
|
||||
let mut edits = Vec::with_capacity(found.len());
|
||||
for (page, c, turn) in found {
|
||||
let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] };
|
||||
let (kind, comb) = match c.kind {
|
||||
pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None),
|
||||
pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
|
||||
pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
|
||||
};
|
||||
let mut props = FieldProps::default();
|
||||
if turn != 0 {
|
||||
// A turned field keeps its place: the rectangle is already the one on the page.
|
||||
props.rotation = Some((0, turn));
|
||||
props.rect = Some((0, c.rect));
|
||||
}
|
||||
if let Some(cells) = comb {
|
||||
props.max_len = Some(Some(cells));
|
||||
props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
|
||||
}
|
||||
edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
|
||||
if turn != 0 || comb.is_some() {
|
||||
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
|
||||
}
|
||||
}
|
||||
self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?;
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
+588
-114
File diff suppressed because it is too large.
Load diff
+57
-24
@@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError {
|
||||
Empty,
|
||||
TooLong,
|
||||
Hidden,
|
||||
UrlOrNetwork,
|
||||
/// A URL, a network or device path, or a path that climbs above its root.
|
||||
NotLocal(craft_docpath::Refused),
|
||||
MissingSourcePath,
|
||||
/// The target doesn't end in `.pdf`.
|
||||
NotPdf,
|
||||
@@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError {
|
||||
Self::Empty => f.write_str("the file specification is empty"),
|
||||
Self::TooLong => f.write_str("the file specification is too long"),
|
||||
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
|
||||
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
|
||||
Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"),
|
||||
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
|
||||
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
|
||||
Self::NotAFile => f.write_str("the linked path is not a regular file"),
|
||||
@@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
|
||||
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
|
||||
/// Relative paths are based on the source document, which should be an absolute path; an
|
||||
/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are
|
||||
/// refused ([`craft_docpath::resolve`]).
|
||||
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
|
||||
if raw.is_empty() || raw.trim().is_empty() {
|
||||
return Err(RemoteDocumentPathError::Empty);
|
||||
@@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<
|
||||
if raw.chars().any(hides_text) {
|
||||
return Err(RemoteDocumentPathError::Hidden);
|
||||
}
|
||||
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
|
||||
return Err(RemoteDocumentPathError::UrlOrNetwork);
|
||||
}
|
||||
let path = Path::new(raw);
|
||||
let drive_absolute = remote_document_path_is_absolute(raw);
|
||||
// A GoToR file specification is a path, not a URL: any scheme of two or more characters is
|
||||
// refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included.
|
||||
if let Some((scheme, _)) = raw.split_once(':') {
|
||||
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
let is_scheme = scheme.len() > 1
|
||||
&& scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
||||
let windows_drive = scheme.len() == 1 && drive_absolute;
|
||||
if valid_scheme && !windows_drive {
|
||||
return Err(RemoteDocumentPathError::UrlOrNetwork);
|
||||
if is_scheme {
|
||||
return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url));
|
||||
}
|
||||
}
|
||||
if drive_absolute {
|
||||
return Ok(path.to_path_buf());
|
||||
let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?;
|
||||
// A relative result would be read against the working directory.
|
||||
if !path.is_absolute() {
|
||||
return Err(RemoteDocumentPathError::MissingSourcePath);
|
||||
}
|
||||
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
|
||||
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
|
||||
Ok(parent.join(path))
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
|
||||
@@ -503,23 +503,56 @@ mod tests {
|
||||
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
|
||||
use std::path::Path;
|
||||
|
||||
let source = Path::new("/docs/chapter/source.pdf");
|
||||
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
|
||||
let source = std::env::temp_dir().join("chapter").join("source.pdf");
|
||||
let source = source.as_path();
|
||||
let folder = source.parent().unwrap();
|
||||
assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf")));
|
||||
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path()));
|
||||
#[cfg(unix)]
|
||||
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
|
||||
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
|
||||
assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used");
|
||||
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
|
||||
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
|
||||
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
|
||||
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
|
||||
}
|
||||
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
|
||||
for url in
|
||||
["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"]
|
||||
{
|
||||
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
|
||||
assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}");
|
||||
}
|
||||
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
|
||||
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_document_paths_to_other_computers_and_devices_are_refused() {
|
||||
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
|
||||
|
||||
let source = std::env::temp_dir().join("chapter").join("source.pdf");
|
||||
let source = source.as_path();
|
||||
// Refused before any filesystem access: none of these exist, and a stat of a network path
|
||||
// would contact its host.
|
||||
for not_local in [
|
||||
"//server/share/a.pdf",
|
||||
"\\\\server\\share\\a.pdf",
|
||||
"\\\\h\\s\\a.pdf",
|
||||
"\\\\?\\UNC\\h\\s\\a.pdf",
|
||||
"\\??\\UNC\\h\\s\\a.pdf",
|
||||
"/??/x",
|
||||
"\\??\\C:\\a.pdf",
|
||||
"\\\\.\\device\\a.pdf",
|
||||
"/\\server/share/a.pdf",
|
||||
"NUL.pdf",
|
||||
] {
|
||||
assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}");
|
||||
}
|
||||
// Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let doubled = format!("/{}", source.with_file_name("a.pdf").display());
|
||||
assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_follow_the_artcraft_scheme() {
|
||||
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));
|
||||
|
||||
@@ -30,6 +30,10 @@ pub struct OcrSettings {
|
||||
/// text read again, not a word in a picture.
|
||||
const ALREADY_ON_PAGE: f32 = 0.5;
|
||||
|
||||
/// The long side of an A4 sheet in points (a letter page's, 792, is shorter): "Downsample to"
|
||||
/// is a resolution on paper this size or smaller.
|
||||
const SHEET: f32 = 842.0;
|
||||
|
||||
/// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page
|
||||
/// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the
|
||||
/// sum is capped at the whole word.
|
||||
@@ -121,11 +125,7 @@ impl OcrJob {
|
||||
out.push(skip("the page already contains text"));
|
||||
continue;
|
||||
}
|
||||
// A scan is read at its own resolution (enlarging it only blurs the letters), at most
|
||||
// the chosen one.
|
||||
let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0;
|
||||
let scale = self.native.get(page).copied().flatten().map_or(wanted, |n| n.clamp(1.0, wanted));
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
let shot = r.render(RenderRequest { page, scale: self.scale(page, info), ..Default::default() });
|
||||
if let Some(e) = shot.error {
|
||||
out.push(skip(&e));
|
||||
continue;
|
||||
@@ -151,6 +151,18 @@ impl OcrJob {
|
||||
progress(total, total);
|
||||
out
|
||||
}
|
||||
|
||||
/// The render scale (pixels per point) `page` is read at. A scan is read at its own
|
||||
/// resolution (enlarging it only blurs the letters), at most the chosen one.
|
||||
fn scale(&self, page: usize, info: &PageInfo) -> f32 {
|
||||
let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0;
|
||||
let Some(native) = self.native.get(page).copied().flatten() else { return wanted };
|
||||
// A scan whose page is larger than a sheet (often a box given in pixels, one point per
|
||||
// pixel, #1848) is measured as if shrunk to one: otherwise 1 px/pt is already its full
|
||||
// image, and no "Downsample to" choice could ask for less.
|
||||
let fit = (SHEET / info.width.max(info.height).max(1e-3)).min(1.0);
|
||||
native.clamp(fit, wanted * fit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -236,12 +248,16 @@ pub fn recognize_file(
|
||||
if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(FileResult { bytes, pages });
|
||||
}
|
||||
Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages })
|
||||
// Checked like a save before the caller writes it (#2166).
|
||||
let bytes = s.save_bytes(id).map_err(|e| e.to_string())?;
|
||||
s.check_save(id, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(FileResult { bytes, pages })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::export;
|
||||
|
||||
/// #1028: a recognised word over the page's own text is that text again; one in an empty
|
||||
/// part of the page (a picture) is new.
|
||||
@@ -260,6 +276,28 @@ mod tests {
|
||||
assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0);
|
||||
}
|
||||
|
||||
/// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel
|
||||
/// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution
|
||||
/// letter page is still never shrunk below one pixel per point.
|
||||
#[test]
|
||||
fn downsample_shrinks_a_page_box_given_in_pixels() {
|
||||
let mut s = Session::new();
|
||||
let page = |w: u32, h: u32| export::encode_png(w, h, &vec![255; (w * h * 4) as usize]).unwrap();
|
||||
let bytes = s.create_from_images(&[("a4.png".into(), page(2480, 3508)), ("letter.png".into(), page(612, 792))]).unwrap();
|
||||
let id = s.open("scan.pdf", None, bytes, None).unwrap();
|
||||
let mut r = PageRenderer::new(s.get(id).unwrap().bytes.clone(), RenderConfig::default());
|
||||
for (dpi, a4) in [(72.0, [595, 842]), (150.0, [1240, 1754]), (300.0, [2480, 3508]), (600.0, [2480, 3508])] {
|
||||
let job = s.ocr_job(id, &[], OcrSettings { dpi, ..Default::default() }).unwrap();
|
||||
for (page, want) in [(0, a4), (1, [612, 792])] {
|
||||
let scale = job.scale(page, &job.infos[page]);
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
assert!(shot.error.is_none(), "{:?}", shot.error);
|
||||
let near = |got: u32, want: u32| got.abs_diff(want) <= 1;
|
||||
assert!(near(shot.width, want[0]) && near(shot.height, want[1]), "{dpi} dpi, page {page}: {}x{}", shot.width, shot.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() {
|
||||
// A glyph drawn twice (fake bold) does not cover more than the whole word.
|
||||
|
||||
Loaded 100 of 406 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user